Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f671024ab | ||
|
|
605a7ca433 | ||
|
|
5170139a32 | ||
|
|
90952a68b7 | ||
|
|
1cc14d7b41 | ||
|
|
aa4d6576f7 | ||
|
|
2a46984f9c | ||
|
|
4a9c721c55 | ||
|
|
7be9995c87 | ||
|
|
85529de3f8 | ||
|
|
114ca9d956 | ||
|
|
29f858685a | ||
|
|
4112faf682 | ||
|
|
a13b73c414 | ||
|
|
667e93af8f | ||
|
|
54a888026e | ||
|
|
a0fb02baa1 | ||
|
|
c445b932db | ||
|
|
ffccb1683b | ||
|
|
0d590fc9d6 | ||
|
|
1d4910f947 | ||
|
|
7e457ed961 | ||
|
|
34722c6985 | ||
|
|
c5a13b50ab | ||
|
|
387bd50281 | ||
|
|
81016fd898 | ||
|
|
39fc1612ac | ||
|
|
7ab12a3fff | ||
|
|
b4d869c469 | ||
|
|
799b667828 | ||
|
|
ae3513edf3 | ||
|
|
6db1861b78 | ||
|
|
a8e839ba18 | ||
|
|
b224cf67d1 | ||
|
|
222237ab34 | ||
|
|
433cfe521e | ||
|
|
1e4a776fa5 | ||
|
|
e88a424a7d | ||
|
|
9450c7902d | ||
|
|
75b5883afe | ||
|
|
c26f0184d9 | ||
|
|
cb74b4bbe7 | ||
|
|
326eca656f | ||
|
|
f0a5058b68 | ||
|
|
d39cd36995 | ||
|
|
0548018b5c | ||
|
|
699e10856e | ||
|
|
36b399cb2f | ||
|
|
f613d58727 | ||
|
|
feccfada77 | ||
|
|
4127e66114 | ||
|
|
b2a9b59399 | ||
|
|
6ffed9c2f2 | ||
|
|
53e193d8d1 | ||
|
|
7835691bc9 | ||
|
|
91715541ec | ||
|
|
e3f9e99129 | ||
|
|
4634cbad89 | ||
|
|
5db2a36da0 | ||
|
|
cd7b273da4 | ||
|
|
cbbad1a6e2 | ||
|
|
9661d4efa7 | ||
|
|
31762c23c5 | ||
|
|
c71a6b4d14 | ||
|
|
e8ddb33f21 | ||
|
|
ea368d19bb | ||
|
|
7ab50b6290 | ||
|
|
fd7cfbd3c3 | ||
|
|
90725af10a | ||
|
|
d63a62a921 | ||
|
|
4bc5481941 | ||
|
|
9feede3b0d | ||
|
|
5aad9eda8d | ||
|
|
91b15faba6 | ||
|
|
98c1c61cbd | ||
|
|
4463a5326a | ||
|
|
bdb23513a4 | ||
|
|
ee40f16232 | ||
|
|
a76944b00c | ||
|
|
cf074ff893 | ||
|
|
712eb19db5 | ||
|
|
de9bc9b170 | ||
|
|
95e2404307 | ||
|
|
2c01feb3be | ||
|
|
f416e0d462 | ||
|
|
7f1308c8ce | ||
|
|
fd8d45e437 | ||
|
|
20390342d5 | ||
|
|
c47bf229f0 | ||
|
|
3616e197e1 | ||
|
|
3d8544de21 | ||
|
|
504abb7a15 | ||
|
|
37021533b9 | ||
|
|
59eab84f03 | ||
|
|
e67033711c | ||
|
|
85fd00f020 | ||
|
|
4f064a1625 | ||
|
|
3bf7ada226 | ||
|
|
3b90e17a1e | ||
|
|
c1fb5b72ee | ||
|
|
29da26cc08 | ||
|
|
807fc09b1e | ||
|
|
528e4994db | ||
|
|
c339122f4f | ||
|
|
feeef46354 | ||
|
|
066bd14554 | ||
|
|
bffaa2ab88 | ||
|
|
31495ea67b | ||
|
|
8f2533f1b2 | ||
|
|
13b0742deb | ||
|
|
e2b2d50e5e | ||
|
|
55d61e37f3 | ||
|
|
fa19f5a704 | ||
|
|
b995031255 | ||
|
|
d096444ca0 | ||
|
|
10358d4f53 | ||
|
|
78f41b8380 | ||
|
|
90d4bc7c04 | ||
|
|
b59c17f120 | ||
|
|
c279e7fd38 | ||
|
|
8a4fcb3e62 | ||
|
|
680ef7088b | ||
|
|
83935af973 | ||
|
|
37973f8fcb | ||
|
|
acf19c281a | ||
|
|
683fce32da | ||
|
|
4c9ba7c30d | ||
|
|
ca1f3ab477 | ||
|
|
2ab09076f5 | ||
|
|
97e17fadd4 | ||
|
|
ef780e5542 | ||
|
|
931c31c7be | ||
|
|
25f2be875c | ||
|
|
ff70326665 | ||
|
|
52ed49211a | ||
|
|
0565fc2a7c | ||
|
|
d30273cda8 | ||
|
|
b893e69b54 | ||
|
|
eabb12ef97 | ||
|
|
225741cf7b | ||
|
|
dad42da6d9 | ||
|
|
0fa2284e89 | ||
|
|
cc241e5e19 | ||
|
|
c5a536fbea | ||
|
|
3a7fef11ef | ||
|
|
625757fafc | ||
|
|
e1a8af5a72 | ||
|
|
c437c89cfa | ||
|
|
184ef5670c | ||
|
|
8ab8915429 | ||
|
|
a952dd2c61 | ||
|
|
ebcd52a019 | ||
|
|
b2ddf0db5c | ||
|
|
eaf4b965d5 | ||
|
|
6ba1c754a1 | ||
|
|
468613ab68 | ||
|
|
261d84bd01 | ||
|
|
ea8db593c5 | ||
|
|
130cc2fc10 | ||
|
|
3715da8fd3 | ||
|
|
100e477a95 | ||
|
|
1132c8c256 | ||
|
|
42237fcc70 | ||
|
|
ae8b3b6b64 | ||
|
|
8378491b0b |
@@ -0,0 +1,17 @@
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
coverage
|
||||
dev-dist
|
||||
dist
|
||||
node_modules
|
||||
node_modules.*
|
||||
output
|
||||
playwright-report
|
||||
test-results
|
||||
.ai
|
||||
.ai-workflow
|
||||
.claude
|
||||
.codex
|
||||
.gradle
|
||||
gradle
|
||||
@@ -0,0 +1,15 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.webm binary
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Frontend Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-upload-and-verify:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
env:
|
||||
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
|
||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
||||
RELEASE_WAIT_INITIAL_SECONDS: 45
|
||||
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
||||
RELEASE_POLL_INTERVAL_SECONDS: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Check AI workflow sync
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
|
||||
- name: Source and i18n checks
|
||||
run: |
|
||||
npm run text:check-encoding
|
||||
npm run i18n:v2:source-check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
|
||||
- name: Build release artifact
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Production Playwright gate
|
||||
run: npm run test:e2e:prod
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
||||
path: dist
|
||||
retention-days: 14
|
||||
|
||||
- name: Wait for Coolify release artifact
|
||||
run: npm run release:verify-upload
|
||||
|
||||
- name: Public live Playwright gate
|
||||
run: npm run test:e2e:live:public
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live Playwright gate
|
||||
run: npm run test:e2e:live:roles
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||
|
||||
- name: Record Release Manager gate
|
||||
run: |
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
curl --fail --show-error --silent \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: Update server version after verification
|
||||
run: npm run release:update-server-version
|
||||
env:
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_VERSION: ${{ github.sha }}
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||
path: output/playwright
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -16,7 +16,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
format-tests:
|
||||
# Match the labels exposed by the Coolify-managed GitHub runner.
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -65,15 +65,35 @@ jobs:
|
||||
if: github.event_name != 'schedule'
|
||||
needs: build-and-unit
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
env:
|
||||
PLAYWRIGHT_PR_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
PLAYWRIGHT_PR_HEAD: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve Playwright diff refs
|
||||
id: playwright-diff
|
||||
shell: bash
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zero_sha="0000000000000000000000000000000000000000"
|
||||
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
|
||||
base_ref="$PR_BASE_SHA"
|
||||
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
|
||||
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
|
||||
base_ref="origin/$DEFAULT_BRANCH"
|
||||
else
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
fi
|
||||
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -87,7 +107,7 @@ jobs:
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright PR tests
|
||||
run: npm run test:e2e:pr -- --base="$PLAYWRIGHT_PR_BASE" --head="$PLAYWRIGHT_PR_HEAD"
|
||||
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:24-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
ARG SOURCE_COMMIT
|
||||
ARG RELEASE_COMMIT_SHA
|
||||
ARG COMMIT_SHA
|
||||
ARG GITHUB_SHA
|
||||
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
ENV RELEASE_COMMIT_SHA=$RELEASE_COMMIT_SHA
|
||||
ENV COMMIT_SHA=$COMMIT_SHA
|
||||
ENV GITHUB_SHA=$GITHUB_SHA
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.coolify-frontend.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -22,6 +22,36 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
By default, the Vite dev server proxies `/api/*` to the remote stable API at
|
||||
`https://api-v2.truckwash.io/master/api`. This lets the Vue app run locally
|
||||
without a local PHP API container.
|
||||
|
||||
To develop against a local PHP API instead:
|
||||
|
||||
```powershell
|
||||
$env:VITE_API_PROXY_TARGET="http://localhost"; npm run dev
|
||||
```
|
||||
|
||||
To use another remote API route:
|
||||
|
||||
```powershell
|
||||
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
|
||||
```
|
||||
|
||||
TLS certificate validation is enabled for proxied HTTPS APIs by default. If you
|
||||
are using a trusted local HTTPS API with a self-signed certificate, you can opt
|
||||
out explicitly:
|
||||
|
||||
```powershell
|
||||
$env:VITE_API_PROXY_TARGET="https://local-api.test"; $env:VITE_API_PROXY_SECURE="false"; npm run dev
|
||||
```
|
||||
|
||||
For compatible local gateways that expect the `/api` prefix to be preserved:
|
||||
|
||||
```powershell
|
||||
$env:VITE_API_PROXY_TARGET="http://localhost"; $env:VITE_API_PROXY_STRIP_PREFIX="false"; npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
|
||||
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_APP_VERSION: string,
|
||||
readonly VITE_COMMIT_HASH: string,
|
||||
readonly VITE_IS_DEV: string,
|
||||
readonly VITE_RELEASE_SOURCE: 'local' | 'deployment' | 'auto',
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
|
||||
+5
-6
@@ -2,11 +2,10 @@
|
||||
<html lang="" class="theme-light" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/png" href="/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
@@ -16,6 +15,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script type="module" src="/src/releaseBootstrap.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
|
||||
location ~ ^/(release-entry|release-manifest)\.json$ {
|
||||
add_header Cache-Control "no-store";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
|
||||
add_header Cache-Control "no-store";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files /$2.json =404;
|
||||
}
|
||||
|
||||
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files /$static_asset_path =404;
|
||||
}
|
||||
|
||||
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||
try_files /$static_file_path =404;
|
||||
}
|
||||
|
||||
location ~ \.[^/]+$ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/(master|beta|canary|internal)/frontend$ {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location ~ ^/(master|beta|canary|internal)/frontend/ {
|
||||
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
+115
-19
@@ -3056,6 +3056,8 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Orders
|
||||
x-api-coverage:
|
||||
happy: true
|
||||
summary: List orders
|
||||
description: Retrieve a paginated list of orders
|
||||
operationId: listOrders
|
||||
@@ -4622,7 +4624,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Add vehicle condition
|
||||
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.
|
||||
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
|
||||
operationId: addSelfserveVehicleCondition
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -4657,14 +4659,6 @@ paths:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the answer mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully added vehicle condition
|
||||
@@ -4681,7 +4675,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Update vehicle condition
|
||||
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles.
|
||||
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
|
||||
operationId: updateSelfserveVehicleCondition
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -4717,14 +4711,6 @@ paths:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully updated vehicle condition
|
||||
@@ -5400,7 +5386,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [department, gateway_id, action]
|
||||
required: [department, gateway_id, action, confirm]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
gateway_id: { type: integer }
|
||||
@@ -8702,6 +8688,116 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfServeLaneStatus'
|
||||
|
||||
/modules/self-serve/lane/wash/my-active-wash:
|
||||
get:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Get the authenticated customer's active self-serve wash
|
||||
description: |
|
||||
Returns the current authenticated customer's open self-serve wash session,
|
||||
if one exists. Regular customers must only receive their own active wash
|
||||
details from this endpoint.
|
||||
operationId: getMyActiveSelfServeWash
|
||||
responses:
|
||||
'200':
|
||||
description: Authenticated customer's active wash details resolved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lane_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
in_progress:
|
||||
type: boolean
|
||||
session:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
lane_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
department_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
reg:
|
||||
type: string
|
||||
customer_number:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
included_minutes:
|
||||
type: integer
|
||||
nullable: true
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
machine_relay_enabled:
|
||||
type: boolean
|
||||
machine_relay_enabled_at:
|
||||
type: string
|
||||
nullable: true
|
||||
machine_start_triggered:
|
||||
type: boolean
|
||||
machine_start_triggered_at:
|
||||
type: string
|
||||
nullable: true
|
||||
wash_started_at:
|
||||
type: string
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
nullable: true
|
||||
customer:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
nullable: true
|
||||
customer_number:
|
||||
type: integer
|
||||
nullable: true
|
||||
display_name:
|
||||
type: string
|
||||
nullable: true
|
||||
email:
|
||||
type: string
|
||||
nullable: true
|
||||
phone_country_code:
|
||||
type: integer
|
||||
nullable: true
|
||||
phone:
|
||||
type: string
|
||||
nullable: true
|
||||
vehicle:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
customer_id:
|
||||
type: integer
|
||||
type:
|
||||
type: integer
|
||||
reg:
|
||||
type: string
|
||||
reference:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
/modules/self-serve/lane/wash/in-progress:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Generated
+44
-45
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "truckwashdashboardsfrontend",
|
||||
"version": "0.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.12.0",
|
||||
"@bubblewrap/cli": "^1.23.0",
|
||||
@@ -42,6 +41,7 @@
|
||||
"dayspan-vuetify": "^0.4.0",
|
||||
"flow": "^0.2.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "11.22.4",
|
||||
@@ -233,7 +233,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1931,6 +1930,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@bubblewrap/core/node_modules/inquirer/node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@bubblewrap/core/node_modules/mute-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz",
|
||||
@@ -1954,7 +1963,8 @@
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@bubblewrap/validator": {
|
||||
"version": "1.22.0",
|
||||
@@ -2077,7 +2087,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -2126,7 +2135,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -2564,7 +2572,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
||||
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"preact": "~10.12.1"
|
||||
}
|
||||
@@ -2808,7 +2815,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz",
|
||||
"integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/core": "^0.22.12"
|
||||
}
|
||||
@@ -2845,7 +2851,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz",
|
||||
"integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/utils": "^0.22.12"
|
||||
},
|
||||
@@ -2858,7 +2863,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz",
|
||||
"integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/utils": "^0.22.12"
|
||||
},
|
||||
@@ -2883,7 +2887,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz",
|
||||
"integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/utils": "^0.22.12",
|
||||
"tinycolor2": "^1.6.0"
|
||||
@@ -2927,7 +2930,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz",
|
||||
"integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/utils": "^0.22.12"
|
||||
},
|
||||
@@ -3051,7 +3053,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz",
|
||||
"integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/utils": "^0.22.12"
|
||||
},
|
||||
@@ -3064,7 +3065,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz",
|
||||
"integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/utils": "^0.22.12"
|
||||
},
|
||||
@@ -3080,7 +3080,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz",
|
||||
"integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jimp/utils": "^0.22.12"
|
||||
},
|
||||
@@ -5071,7 +5070,6 @@
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.9",
|
||||
@@ -5087,7 +5085,6 @@
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
@@ -5343,7 +5340,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5378,7 +5374,6 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -5472,8 +5467,7 @@
|
||||
"version": "5.10.4",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.4.tgz",
|
||||
"integrity": "sha512-gt0VUqZ2+mr25ScbUcKZgJr96jKYm4vjOcxEWCEh/E5F4dWqhyo3dBhPRvNNnkKiWxkMd2cBwj3ZYH3rK39fkA==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"peer": true
|
||||
"license": "SEE LICENSE IN LICENSE"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.1",
|
||||
@@ -5597,7 +5591,6 @@
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
@@ -5743,7 +5736,6 @@
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -5821,7 +5813,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5906,8 +5897,7 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz",
|
||||
"integrity": "sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bulma-block-list": {
|
||||
"version": "1.1.0",
|
||||
@@ -6165,7 +6155,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -6434,7 +6423,6 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-what": "^5.2.0"
|
||||
@@ -6588,7 +6576,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -6777,7 +6764,6 @@
|
||||
"integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==",
|
||||
"deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-sfc": "2.7.16",
|
||||
"csstype": "^3.1.0"
|
||||
@@ -8335,8 +8321,7 @@
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
@@ -9000,7 +8985,6 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9583,7 +9567,6 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
@@ -10121,7 +10104,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
@@ -10183,6 +10165,34 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.5.0",
|
||||
"vue": "^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/pixelmatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
|
||||
@@ -10403,7 +10413,6 @@
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
@@ -10741,7 +10750,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
@@ -10758,7 +10766,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -10942,7 +10949,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
|
||||
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.1.5",
|
||||
@@ -10964,7 +10970,6 @@
|
||||
"integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.5.0",
|
||||
"colorjs.io": "^0.5.0",
|
||||
@@ -11662,7 +11667,6 @@
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -11934,7 +11938,6 @@
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-anything": "^4"
|
||||
@@ -12730,7 +12733,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
|
||||
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14123,7 +14125,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -14183,7 +14184,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz",
|
||||
"integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.31",
|
||||
"@vue/compiler-sfc": "3.5.31",
|
||||
@@ -14871,7 +14871,6 @@
|
||||
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
|
||||
+6
-1
@@ -11,7 +11,6 @@
|
||||
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
||||
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
||||
"prepare": "node scripts/prepare-husky.mjs",
|
||||
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
|
||||
"preview": "vite preview",
|
||||
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
|
||||
@@ -42,7 +41,12 @@
|
||||
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
|
||||
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
|
||||
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
|
||||
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
|
||||
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
|
||||
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
|
||||
"release:verify-upload": "node scripts/release/verify-upload.mjs",
|
||||
"release:update-server-version": "node scripts/release/update-server-version.mjs",
|
||||
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
|
||||
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
|
||||
"test:all": "npm run test:unit && npm run test:e2e:pr",
|
||||
"twa:build": "bubblewrap build",
|
||||
@@ -82,6 +86,7 @@
|
||||
"dayspan-vuetify": "^0.4.0",
|
||||
"flow": "^0.2.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "11.22.4",
|
||||
|
||||
+33
-29
@@ -1,4 +1,5 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
@@ -11,15 +12,16 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}
|
||||
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
|
||||
const serverLogDir = path.resolve(process.cwd(), "output/playwright");
|
||||
const pidFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.json`);
|
||||
const stdoutLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stdout.log`);
|
||||
const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stderr.log`);
|
||||
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
|
||||
const serverOutputLimit = 80;
|
||||
const activeOutputReaders = [];
|
||||
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
|
||||
const viteDevArgs = [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--force",
|
||||
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
|
||||
...(process.platform === "win32" ? ["--configLoader", "runner"] : []),
|
||||
"--host",
|
||||
devHost,
|
||||
@@ -176,14 +178,18 @@ async function killProcessTree(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
function captureProcessOutput(stream, lines) {
|
||||
function captureProcessOutput(stream, lines, logStream) {
|
||||
const reader = readline.createInterface({ input: stream });
|
||||
reader.on("line", (line) => {
|
||||
lines.push(line);
|
||||
logStream.write(`${line}\n`);
|
||||
if (lines.length > serverOutputLimit) {
|
||||
lines.splice(0, lines.length - serverOutputLimit);
|
||||
}
|
||||
});
|
||||
reader.on("close", () => {
|
||||
logStream.end();
|
||||
});
|
||||
return reader;
|
||||
}
|
||||
|
||||
@@ -368,6 +374,10 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}
|
||||
}
|
||||
|
||||
for (const specifier of extractModuleImports(source)) {
|
||||
if (specifier.startsWith("/node_modules/.vite/deps/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedContentType = resolveExpectedContentType(specifier);
|
||||
const importUrl = new URL(specifier, current.url).toString();
|
||||
|
||||
@@ -436,31 +446,25 @@ export default async function globalSetup() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
const serverProcess =
|
||||
process.platform === "win32"
|
||||
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd ${viteDevArgs.join(" ")}`], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
})
|
||||
: spawn("npm", viteDevArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
const stdoutLines = [];
|
||||
const stderrLines = [];
|
||||
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
|
||||
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
|
||||
const stdoutLogStream = createWriteStream(stdoutLogFile, { flags: "w" });
|
||||
const stderrLogStream = createWriteStream(stderrLogFile, { flags: "w" });
|
||||
serverProcess.on("exit", (code, signal) => {
|
||||
stderrLogStream.write(`[playwright-global-setup] vite exited with code ${code ?? "null"} signal ${signal ?? "null"}\n`);
|
||||
});
|
||||
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines, stdoutLogStream);
|
||||
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines, stderrLogStream);
|
||||
activeOutputReaders.push(stdoutReader, stderrReader);
|
||||
serverProcess.unref();
|
||||
|
||||
|
||||
+42
-2
@@ -4,10 +4,50 @@
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
RewriteRule ^index\.html$ - [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule \.[^/]+$ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . index.html [L]
|
||||
</IfModule>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "Access your Truck Wash accounts and transactions from anywhere.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicons/web-app-manifest-192x192.png",
|
||||
"src": "assets/favicons/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/favicons/web-app-manifest-512x512.png",
|
||||
"src": "assets/favicons/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
|
||||
@@ -34,6 +34,12 @@ export const sourceMappings = [
|
||||
],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "user-vehicles",
|
||||
patterns: [/^src\/components\/displays\/user\/vehicles\//u, /^src\/views\/dashboards\/userDashboard\/vehicles\//u],
|
||||
specs: ["tests/e2e/userVehicles.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "pos",
|
||||
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const helperScriptPath = path.resolve(process.cwd(), "..", "scripts", "sync-playwright-root-links.mjs");
|
||||
|
||||
if (!fs.existsSync(helperScriptPath)) {
|
||||
console.log(
|
||||
`Skipping root Playwright link sync: helper script not found at ${helperScriptPath}.`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [helperScriptPath], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (typeof result.status === "number") {
|
||||
process.exit(result.status);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
function gitCommit() {
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = process.env.SERVER_UPDATE_TOKEN;
|
||||
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
|
||||
if (!token) {
|
||||
if (required) {
|
||||
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
|
||||
}
|
||||
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
|
||||
return;
|
||||
}
|
||||
|
||||
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
|
||||
if (!version) {
|
||||
throw new Error("Could not determine release version for server update.");
|
||||
}
|
||||
|
||||
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set("version", version);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
|
||||
}
|
||||
|
||||
console.log(`Server version updated to ${version}.`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
REMOTE_ROOT="${RELEASE_DEPLOY_REMOTE_ROOT:-/}"
|
||||
HOST="${RELEASE_DEPLOY_HOST:-}"
|
||||
USER_NAME="${RELEASE_DEPLOY_USER:-}"
|
||||
PASSWORD="${RELEASE_DEPLOY_PASSWORD:-}"
|
||||
DEPLOY_URL="${RELEASE_DEPLOY_URL:-}"
|
||||
|
||||
if [[ ! -d "$DIST_DIR" ]]; then
|
||||
echo "Missing dist directory: $DIST_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v lftp >/dev/null 2>&1; then
|
||||
echo "lftp is required for FTP/SFTP uploads." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$DEPLOY_URL" ]]; then
|
||||
if [[ -z "$HOST" || -z "$USER_NAME" || -z "$PASSWORD" ]]; then
|
||||
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_URL="ftp://${HOST}"
|
||||
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
|
||||
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
|
||||
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
lftp_quote() {
|
||||
local value="${1//\'/\'\\\'\'}"
|
||||
printf "'%s'" "$value"
|
||||
}
|
||||
|
||||
run_lftp() {
|
||||
local transfer_command="$1"
|
||||
|
||||
{
|
||||
printf 'set ftp:ssl-allow true\n'
|
||||
printf 'set ftp:ssl-force true\n'
|
||||
printf 'set ftp:ssl-protect-data true\n'
|
||||
printf 'set net:max-retries 3\n'
|
||||
printf 'set net:timeout 20\n'
|
||||
|
||||
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
|
||||
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
|
||||
else
|
||||
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
|
||||
fi
|
||||
|
||||
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
|
||||
printf '%s\n' "$transfer_command"
|
||||
printf 'bye\n'
|
||||
} | lftp -f /dev/stdin
|
||||
}
|
||||
|
||||
upload_file() {
|
||||
local source_file="$1"
|
||||
local remote_file="$2"
|
||||
if [[ -f "$source_file" ]]; then
|
||||
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")"
|
||||
fi
|
||||
}
|
||||
|
||||
for directory in assets resources favicons icons img sounds .well-known; do
|
||||
if [[ -d "$DIST_DIR/$directory" ]]; then
|
||||
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
|
||||
fi
|
||||
done
|
||||
|
||||
find "$DIST_DIR" -maxdepth 1 -type f \
|
||||
! -name 'index.html' \
|
||||
! -name 'release-entry.json' \
|
||||
! -name 'release-manifest.json' \
|
||||
-print0 | while IFS= read -r -d '' file; do
|
||||
upload_file "$file" "$(basename "$file")"
|
||||
done
|
||||
|
||||
upload_file "$DIST_DIR/release-manifest.json" "release-manifest.json"
|
||||
upload_file "$DIST_DIR/release-entry.json" "release-entry.json"
|
||||
upload_file "$DIST_DIR/index.html" "index.html"
|
||||
@@ -0,0 +1,215 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function numberEnv(name, fallback) {
|
||||
const value = Number.parseInt(process.env[name] || "", 10);
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function requiredUrl() {
|
||||
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
|
||||
if (!value) {
|
||||
throw new Error("RELEASE_BASE_URL or PLAYWRIGHT_BASE_URL is required.");
|
||||
}
|
||||
|
||||
return value.endsWith("/") ? value : `${value}/`;
|
||||
}
|
||||
|
||||
function pathUrl(baseUrl, assetPath) {
|
||||
const path = String(assetPath || "").replace(/^\/+/, "");
|
||||
return new URL(path, baseUrl).href;
|
||||
}
|
||||
|
||||
async function fetchBytes(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
});
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
response,
|
||||
bytes,
|
||||
text: () => bytes.toString("utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(baseUrl, assetPath) {
|
||||
const url = pathUrl(baseUrl, assetPath);
|
||||
const result = await fetchBytes(url);
|
||||
const contentType = result.response.headers.get("content-type") || "";
|
||||
if (!result.response.ok) {
|
||||
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
|
||||
}
|
||||
if (contentType.includes("text/html")) {
|
||||
throw new Error(`${assetPath} was served as HTML`);
|
||||
}
|
||||
if (result.bytes.length === 0) {
|
||||
throw new Error(`${assetPath} was empty`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(result.text());
|
||||
} catch (error) {
|
||||
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function compareCommit(actual, expected) {
|
||||
if (!expected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return actual === expected || actual.startsWith(expected);
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function shouldRejectHtml(assetPath) {
|
||||
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
|
||||
}
|
||||
|
||||
async function verifyAsset(baseUrl, assetPath, expectedHash) {
|
||||
const url = pathUrl(baseUrl, assetPath);
|
||||
const result = await fetchBytes(url);
|
||||
const contentType = result.response.headers.get("content-type") || "";
|
||||
|
||||
if (!result.response.ok) {
|
||||
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
|
||||
}
|
||||
if (result.bytes.length === 0) {
|
||||
throw new Error(`${assetPath} was empty`);
|
||||
}
|
||||
if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) {
|
||||
throw new Error(`${assetPath} was served as HTML (${contentType})`);
|
||||
}
|
||||
if (expectedHash?.sha256) {
|
||||
const actualHash = sha256(result.bytes);
|
||||
if (actualHash !== expectedHash.sha256) {
|
||||
throw new Error(`${assetPath} sha256 mismatch: expected ${expectedHash.sha256}, got ${actualHash}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyShell(baseUrl, shellPath) {
|
||||
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
|
||||
const contentType = result.response.headers.get("content-type") || "";
|
||||
const body = result.text();
|
||||
|
||||
if (!result.response.ok) {
|
||||
throw new Error(`${shellPath} returned HTTP ${result.response.status}`);
|
||||
}
|
||||
if (!contentType.includes("text/html")) {
|
||||
throw new Error(`${shellPath} did not return HTML (${contentType})`);
|
||||
}
|
||||
if (body.replace(/\s+/g, "").length < 40) {
|
||||
throw new Error(`${shellPath} returned an empty app shell`);
|
||||
}
|
||||
if (!body.includes('<div id="app"></div>')) {
|
||||
throw new Error(`${shellPath} did not include the Vue app root`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyRelease(baseUrl) {
|
||||
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
|
||||
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
|
||||
const manifest = await fetchJson(baseUrl, "release-manifest.json");
|
||||
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||
|
||||
if (!manifest.build_id) {
|
||||
throw new Error("release-manifest.json is missing build_id");
|
||||
}
|
||||
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
|
||||
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
|
||||
}
|
||||
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
|
||||
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
|
||||
}
|
||||
if (releaseEntry.entry !== manifest.entry) {
|
||||
throw new Error("release-entry.json entry does not match release-manifest.json");
|
||||
}
|
||||
|
||||
const releaseCss = JSON.stringify(releaseEntry.css || []);
|
||||
const manifestCss = JSON.stringify(manifest.css || []);
|
||||
if (releaseCss !== manifestCss) {
|
||||
throw new Error("release-entry.json css does not match release-manifest.json");
|
||||
}
|
||||
|
||||
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
|
||||
for (const shellPath of shellPaths) {
|
||||
await verifyShell(baseUrl, shellPath);
|
||||
}
|
||||
|
||||
const assetUrls = unique([
|
||||
"release-manifest.json",
|
||||
"release-entry.json",
|
||||
manifest.entry,
|
||||
...(manifest.css || []),
|
||||
...(manifest.index_asset_urls || []),
|
||||
...(manifest.pwa_asset_urls || []),
|
||||
...(manifest.asset_urls || []),
|
||||
]);
|
||||
|
||||
for (const assetUrl of assetUrls) {
|
||||
if (assetUrl === "/index.html") {
|
||||
continue;
|
||||
}
|
||||
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
|
||||
}
|
||||
|
||||
return {
|
||||
build_id: manifest.build_id,
|
||||
commit_sha: manifest.commit_sha,
|
||||
assets: assetUrls.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseUrl = requiredUrl();
|
||||
const initialWaitSeconds = numberEnv("RELEASE_WAIT_INITIAL_SECONDS", 30);
|
||||
const timeoutSeconds = numberEnv("RELEASE_WAIT_TIMEOUT_SECONDS", 300);
|
||||
const pollIntervalSeconds = numberEnv("RELEASE_POLL_INTERVAL_SECONDS", 10);
|
||||
|
||||
if (initialWaitSeconds > 0) {
|
||||
console.log(`Waiting ${initialWaitSeconds}s before polling ${baseUrl}`);
|
||||
await sleep(initialWaitSeconds * 1000);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
let attempt = 0;
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
attempt += 1;
|
||||
try {
|
||||
const result = await verifyRelease(baseUrl);
|
||||
console.log(
|
||||
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`Release upload not ready on attempt ${attempt}: ${message}`);
|
||||
if (Date.now() > deadline) {
|
||||
break;
|
||||
}
|
||||
await sleep(pollIntervalSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Release upload verification timed out.");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,25 +1,34 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const workingDirectory = process.cwd();
|
||||
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
||||
const execFileAsync = promisify(execFile);
|
||||
const roles = ["customer", "subuser", "admin", "superuser"];
|
||||
export const roles = ["customer", "subuser", "admin", "superuser"];
|
||||
const listEntryPattern = /^\s+\[[^\]]+\]\s+›\s+(.+?):(\d+):(\d+)\s+›\s+(.+)\s*$/u;
|
||||
|
||||
const ownedFilesByRole = {
|
||||
export const ownedFilesByRole = {
|
||||
customer: [
|
||||
"auth.smoke.spec.js",
|
||||
"booking-selfserve.smoke.spec.js",
|
||||
"connectivityIssue.spec.ts",
|
||||
"example.spec.ts",
|
||||
"guest-book-wash-mobile.spec.ts",
|
||||
"i18n-catalog-switch.spec.ts",
|
||||
"i18n-v2-integrity.spec.ts",
|
||||
"i18n.smoke.spec.ts",
|
||||
"i18n.views.spec.ts",
|
||||
"navigation.smoke.spec.js",
|
||||
"qr-new-customer-layout.spec.ts",
|
||||
"release-bootstrap.spec.js",
|
||||
"release-channel-switched.spec.js",
|
||||
"release-channel-unavailable.spec.js",
|
||||
"release-update-widget.spec.js",
|
||||
"self-serve-wash.spec.js",
|
||||
"session-release-runtime.spec.ts",
|
||||
"user-orders.spec.ts",
|
||||
"userBookings.spec.ts",
|
||||
"userBookWash.spec.ts",
|
||||
@@ -53,6 +62,7 @@ const ownedFilesByRole = {
|
||||
"assign-draft-order-modal-layout.spec.ts",
|
||||
"change-invoice-collection.spec.ts",
|
||||
"change-customer.spec.ts",
|
||||
"default-mobile-redirect.spec.ts",
|
||||
"economic-queue-workflow.spec.js",
|
||||
"pos-customer-rules.spec.js",
|
||||
"pos-desktop-card-payments.spec.js",
|
||||
@@ -62,18 +72,23 @@ const ownedFilesByRole = {
|
||||
"pos.visual.spec.js",
|
||||
],
|
||||
superuser: [
|
||||
"coolify-infrastructure.spec.js",
|
||||
"edge-gateways.fleet-outline.spec.js",
|
||||
"edge-gateways.routes.spec.js",
|
||||
"edge-gateways.smoke.spec.js",
|
||||
"edge-gateways.visual.spec.js",
|
||||
"errorReports.spec.ts",
|
||||
"failover-config.source.spec.ts",
|
||||
"invoice-distribution.smoke.spec.js",
|
||||
"invoice-transfer-monitor.spec.ts",
|
||||
"invoice-transfer-queue-history.spec.js",
|
||||
"invoicing-period.smoke.spec.js",
|
||||
"issue-repro-duplicates-date.spec.js",
|
||||
"release-manager.spec.js",
|
||||
"self-serve-sessions.spec.js",
|
||||
"self-serve-studio-audit-navigation.spec.js",
|
||||
"self-serve-studio-flow.spec.js",
|
||||
"session-bootstrap.spec.ts",
|
||||
"superuser-bookings.spec.ts",
|
||||
"superuser-customer-complaints.spec.ts",
|
||||
"superuser-customers-mass-import.spec.ts",
|
||||
@@ -84,12 +99,13 @@ const ownedFilesByRole = {
|
||||
"superuser-drafts.spec.ts",
|
||||
"superuser-products-layout.spec.ts",
|
||||
"superuser-system-status.smoke.spec.js",
|
||||
"superuser-users.spec.ts",
|
||||
"superuser-vehicles.smoke.spec.js",
|
||||
"workfeed-config.smoke.spec.js",
|
||||
],
|
||||
};
|
||||
|
||||
const titleRules = [
|
||||
export const titleRules = [
|
||||
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
|
||||
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
|
||||
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
|
||||
@@ -116,6 +132,11 @@ const titleRules = [
|
||||
file: "subuser-management.spec.ts",
|
||||
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
|
||||
},
|
||||
{
|
||||
role: "superuser",
|
||||
file: "subuser-management.spec.ts",
|
||||
patterns: [/^superusers can list and invite chauffeurs/i],
|
||||
},
|
||||
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
|
||||
];
|
||||
|
||||
@@ -200,7 +221,7 @@ function toBaseName(filePath) {
|
||||
return filePath.split(/[\\/]/u).pop() || filePath;
|
||||
}
|
||||
|
||||
function parseListedTests(listOutput) {
|
||||
export function parseListedTests(listOutput) {
|
||||
return listOutput
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => {
|
||||
@@ -222,7 +243,7 @@ function parseListedTests(listOutput) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function classifyTest(testEntry) {
|
||||
export function classifyTest(testEntry) {
|
||||
const matches = new Set();
|
||||
const directOwner = ownedFileToRole.get(testEntry.fileName);
|
||||
|
||||
@@ -309,8 +330,8 @@ async function runPlaywright(project, testListPath, forwardedArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const { options, forwardedArgs } = parseCliArgs(argv);
|
||||
validateOptions(options, forwardedArgs);
|
||||
|
||||
const listOutput = await listProjectTests(options.project, forwardedArgs);
|
||||
@@ -342,4 +363,17 @@ async function main() {
|
||||
await runPlaywright(options.project, testListPath, forwardedArgs);
|
||||
}
|
||||
|
||||
await main();
|
||||
async function isDirectRun() {
|
||||
if (!process.argv[1]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPath = await fs.realpath(fileURLToPath(import.meta.url));
|
||||
const invokedPath = await fs.realpath(process.argv[1]).catch(() => path.resolve(process.argv[1]));
|
||||
|
||||
return currentPath === invokedPath;
|
||||
}
|
||||
|
||||
if (await isDirectRun()) {
|
||||
await main();
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ async function runPlaywright({ label, commandArgs, artifactSuffix }) {
|
||||
PLAYWRIGHT: "1",
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix),
|
||||
PLAYWRIGHT_REPORTER_MODE: "line-html",
|
||||
PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS || "1",
|
||||
},
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
@@ -318,11 +319,19 @@ function groupSpecsByProjects(specProjects) {
|
||||
|
||||
async function runCorePrGate() {
|
||||
const projects = getSelectedProjects();
|
||||
return runPlaywright({
|
||||
label: `core ${prGrep} gate`,
|
||||
artifactSuffix: "core",
|
||||
commandArgs: ["--grep", prGrep, ...buildProjectArgs(projects)],
|
||||
});
|
||||
for (const project of projects) {
|
||||
const code = await runPlaywright({
|
||||
label: `core ${prGrep} gate (${project})`,
|
||||
artifactSuffix: `core-${project}`,
|
||||
commandArgs: ["--grep", prGrep, "--project", project],
|
||||
});
|
||||
|
||||
if (code !== 0) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function runChangedSelection(selection) {
|
||||
@@ -332,11 +341,19 @@ async function runChangedSelection(selection) {
|
||||
console.log(
|
||||
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
|
||||
);
|
||||
return runPlaywright({
|
||||
label: `fallback ${smokeGrep} gate`,
|
||||
artifactSuffix: "smoke-fallback",
|
||||
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, ...buildProjectArgs(projects)],
|
||||
});
|
||||
for (const project of projects) {
|
||||
const code = await runPlaywright({
|
||||
label: `fallback ${smokeGrep} gate (${project})`,
|
||||
artifactSuffix: `smoke-fallback-${project}`,
|
||||
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, "--project", project],
|
||||
});
|
||||
|
||||
if (code !== 0) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const groups = groupSpecsByProjects(selection.specProjects);
|
||||
@@ -346,14 +363,16 @@ async function runChangedSelection(selection) {
|
||||
}
|
||||
|
||||
for (const [index, group] of groups.entries()) {
|
||||
const code = await runPlaywright({
|
||||
label: `changed-area specs ${index + 1}/${groups.length}`,
|
||||
artifactSuffix: `changed-${index + 1}`,
|
||||
commandArgs: [...group.specs, ...buildProjectArgs(group.projects)],
|
||||
});
|
||||
for (const project of group.projects) {
|
||||
const code = await runPlaywright({
|
||||
label: `changed-area specs ${index + 1}/${groups.length} (${project})`,
|
||||
artifactSuffix: `changed-${index + 1}-${project}`,
|
||||
commandArgs: [...group.specs, "--project", project],
|
||||
});
|
||||
|
||||
if (code !== 0) {
|
||||
return code;
|
||||
if (code !== 0) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const { t, te, locale } = useI18n({ useScope: "global" });
|
||||
const APP_TITLE = "Truck Wash";
|
||||
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
|
||||
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
|
||||
const VersionCheck = defineAsyncComponent(() => import("@/components/global/VersionCheck.vue"));
|
||||
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
|
||||
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
|
||||
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
|
||||
@@ -123,7 +122,6 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
||||
<header></header>
|
||||
<main>
|
||||
<DefaultPageWrapper>
|
||||
<VersionCheck />
|
||||
<router-view />
|
||||
</DefaultPageWrapper>
|
||||
</main>
|
||||
|
||||
@@ -760,6 +760,7 @@
|
||||
|
||||
.pos-step-one-insights {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
@@ -903,6 +904,7 @@
|
||||
|
||||
.pos-step-one-insight-card__section {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
@@ -970,6 +972,7 @@
|
||||
.pos-step-one-empty-state,
|
||||
.pos-step-one-loading-state {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 4rem;
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
<script setup>
|
||||
import CustomerComplaintsPagination from "@/components/displays/pagination/models/SuperUserDashboard/CustomerComplaintsPagination.vue";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="superuser-complaints-page">
|
||||
<PageTitle
|
||||
:title="t('superuser.pages.complaints.title')"
|
||||
:subtitle="t('superuser.pages.complaints.subtitle')"
|
||||
/>
|
||||
<CustomerComplaintsPagination auto-load="true" />
|
||||
<CustomerComplaintsPagination :auto-load="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { getDepartmentListData } from "@/components/session/Session.vue";
|
||||
import { showCreateDepartmentForm } from "@/components/forms/superUser/createDepartmentForm.vue";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import DepartmentsPagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentsPagination.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const tableData = ref([]);
|
||||
|
||||
getDepartmentListData().then((response) => {
|
||||
tableData.value = response.data.data;
|
||||
});
|
||||
|
||||
const redirect = (path) => {
|
||||
window.location = path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,4 +25,4 @@ const redirect = (path) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -6,15 +6,35 @@ import SubusersPagination from "@/components/displays/pagination/models/SuperUse
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: {
|
||||
type: String,
|
||||
default: "/subusers",
|
||||
},
|
||||
showCustomer: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
superuserPage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
const canInvite = computed(() => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD"));
|
||||
const canInvite = computed(() =>
|
||||
props.superuserPage
|
||||
? SessionUser.canAccessSuperUser()
|
||||
: SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD")
|
||||
);
|
||||
const requiresGrantSelection = computed(
|
||||
() => SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
|
||||
() => !props.superuserPage && SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
|
||||
);
|
||||
const paginationVersion = ref(0);
|
||||
const paginationKey = computed(() =>
|
||||
SessionUser.isSubuser.value
|
||||
props.superuserPage
|
||||
? `subusers-superuser-${paginationVersion.value}`
|
||||
: SessionUser.isSubuser.value
|
||||
? `subusers-${SessionUser.subuser.selectedGrantCustomerNumber.value || "none"}-${paginationVersion.value}`
|
||||
: `subusers-user-${paginationVersion.value}`
|
||||
);
|
||||
@@ -22,13 +42,13 @@ const paginationKey = computed(() =>
|
||||
const onInviteClick = async () => {
|
||||
await SessionUser.objects.subusers.functions.showInviteForm(() => {
|
||||
paginationVersion.value += 1;
|
||||
});
|
||||
}, { superuser: props.superuserPage });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageTitle :title="t('superuser.pages.subusers.title')" :subtitle="t('superuser.pages.subusers.subtitle')">
|
||||
<PageTitle :title="SessionUser.objects.subusers.meta.title" :subtitle="t('superuser.pages.subusers.subtitle')">
|
||||
<template #buttons>
|
||||
<button v-if="canInvite" class="button is-dark" type="button" @click="onInviteClick">
|
||||
<span class="icon">
|
||||
@@ -39,7 +59,7 @@ const onInviteClick = async () => {
|
||||
</template>
|
||||
</PageTitle>
|
||||
|
||||
<div v-if="SessionUser.isSubuser.value" class="mb-5">
|
||||
<div v-if="SessionUser.isSubuser.value && !superuserPage" class="mb-5">
|
||||
<SubuserGrantSelector />
|
||||
<p class="help">
|
||||
Vælg den kunde, du vil administrere chauffører for. Listen og rettighederne følger det valgte kundenummer.
|
||||
@@ -50,7 +70,13 @@ const onInviteClick = async () => {
|
||||
Vælg først en kunde for at se og administrere chauffører.
|
||||
</div>
|
||||
|
||||
<SubusersPagination v-else :key="paginationKey" auto-load="true" />
|
||||
<SubusersPagination
|
||||
v-else
|
||||
:key="paginationKey"
|
||||
:endpoint="endpoint"
|
||||
:show-customer="showCustomer"
|
||||
auto-load="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ref } from "vue";
|
||||
import CustomerModal from "@/components/displays/modals/CustomerModal.vue";
|
||||
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { API_URL } from "@/config.js";
|
||||
import { getReleaseRuntimeApiBaseUrl } from "@/services/releaseTimeline.js";
|
||||
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
|
||||
import {
|
||||
buildCustomerAttributeTargetPayload,
|
||||
@@ -63,6 +63,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
reg_3: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
order_booking_id: {
|
||||
type: Number,
|
||||
default: null,
|
||||
@@ -93,6 +97,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowBookingDeletion: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
department_lane_id: {
|
||||
type: Number,
|
||||
default: null,
|
||||
@@ -839,6 +847,38 @@ watch(isDropdownOpen, async (isOpen) => {
|
||||
|
||||
const attachmentsFromOrder = ref([]);
|
||||
const attachmentsFromOrderError = ref(null);
|
||||
const SELF_SERVE_WASH_ATTACHMENT_TYPE = "SELF_SERVE_WASH";
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const getAttachmentOtherPayload = (attachment) => attachment?.content?.other ?? null;
|
||||
|
||||
const isSelfServeWashAttachment = (attachment) => {
|
||||
const other = getAttachmentOtherPayload(attachment);
|
||||
return Boolean(other && typeof other === "object" && other.type === SELF_SERVE_WASH_ATTACHMENT_TYPE);
|
||||
};
|
||||
|
||||
const getSelfServeWashAttachment = computed(() =>
|
||||
attachmentsFromOrder.value.find((attachment) => isSelfServeWashAttachment(attachment)) || null
|
||||
);
|
||||
|
||||
const getSelfServeWashPayload = computed(() => getAttachmentOtherPayload(getSelfServeWashAttachment.value));
|
||||
|
||||
const getSelfServeWashCustomerNumber = computed(() =>
|
||||
normalizePositiveInteger(getSelfServeWashPayload.value?.customer_number)
|
||||
);
|
||||
|
||||
const canAcceptSelfServeWashDraft = computed(() =>
|
||||
Boolean(
|
||||
props.order_id
|
||||
&& getSelfServeWashCustomerNumber.value
|
||||
&& normalizePositiveInteger(props.customer_number) !== getSelfServeWashCustomerNumber.value
|
||||
&& (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
|
||||
)
|
||||
);
|
||||
|
||||
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
|
||||
|
||||
@@ -863,16 +903,39 @@ const clearAttachmentPreviewState = () => {
|
||||
};
|
||||
|
||||
const getAttachmentLabel = (attachment) => {
|
||||
if (isSelfServeWashAttachment(attachment)) {
|
||||
const customerNumber = normalizePositiveInteger(attachment?.content?.other?.customer_number);
|
||||
return customerNumber
|
||||
? t("admin.pos.settings_wheel.self_serve_wash_attachment_for_customer", { customerNumber })
|
||||
: t("admin.pos.settings_wheel.self_serve_wash_attachment");
|
||||
}
|
||||
|
||||
const other = getAttachmentOtherPayload(attachment);
|
||||
const otherLabel = typeof other === "string"
|
||||
? other
|
||||
: other && typeof other === "object"
|
||||
? (other.label || other.type || JSON.stringify(other))
|
||||
: null;
|
||||
|
||||
return (
|
||||
attachment?.content?.document ||
|
||||
attachment?.content?.image ||
|
||||
attachment?.content?.other ||
|
||||
otherLabel ||
|
||||
`Attachment ${attachment?.id ?? ""}`.trim()
|
||||
);
|
||||
};
|
||||
|
||||
const isWashCertificateAttachment = (attachment) => {
|
||||
const marker = String(getAttachmentOtherPayload(attachment) || "").trim().toUpperCase();
|
||||
if (marker === "WASH_CERTIFICATE") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /(?:^|[/\\])wash[_-]?certificate.*\.pdf$/i.test(getAttachmentLabel(attachment));
|
||||
};
|
||||
|
||||
const getAttachmentExtension = (attachment) => {
|
||||
const match = getAttachmentLabel(attachment)
|
||||
const match = String(getAttachmentLabel(attachment))
|
||||
.toLowerCase()
|
||||
.match(/(\.[a-z0-9]+)$/);
|
||||
|
||||
@@ -894,17 +957,51 @@ const getAttachmentPreviewKind = (attachment) => {
|
||||
return "office";
|
||||
}
|
||||
|
||||
if (String(attachment?.content?.other || "").startsWith("http")) {
|
||||
const other = getAttachmentOtherPayload(attachment);
|
||||
if (typeof other === "string" && other.startsWith("http")) {
|
||||
return "link";
|
||||
}
|
||||
|
||||
if (attachment?.content?.other) {
|
||||
if (other) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
return "none";
|
||||
};
|
||||
|
||||
const formatAttachmentText = (attachment) => {
|
||||
const other = getAttachmentOtherPayload(attachment);
|
||||
if (isSelfServeWashAttachment(attachment)) {
|
||||
const parts = [
|
||||
t("admin.pos.settings_wheel.self_serve_wash_attachment"),
|
||||
other?.customer_number
|
||||
? `${t("admin.pos.settings_wheel.self_serve_customer")}: #${other.customer_number}`
|
||||
: null,
|
||||
other?.subuser?.name || other?.subuser?.username || other?.subuser_id
|
||||
? `${t("admin.pos.settings_wheel.self_serve_driver")}: ${other?.subuser?.name || other?.subuser?.username || `#${other.subuser_id}`}`
|
||||
: null,
|
||||
other?.license_plate
|
||||
? `${t("pos.license_plate")}: ${other.license_plate}`
|
||||
: null,
|
||||
other?.elapsed_wash_time_seconds
|
||||
? `${t("admin.pos.settings_wheel.self_serve_elapsed")}: ${Math.ceil(Number(other.elapsed_wash_time_seconds) / 60)} min`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
if (typeof other === "string") {
|
||||
return other;
|
||||
}
|
||||
|
||||
if (other && typeof other === "object") {
|
||||
return JSON.stringify(other, null, 2);
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const getAttachmentPreviewPlaceholderIcon = (attachment) => {
|
||||
const previewKind = getAttachmentPreviewKind(attachment);
|
||||
|
||||
@@ -965,6 +1062,10 @@ const activeAttachmentPreviewSource = computed(() => {
|
||||
return previewSourcesById.value[activeAttachment.value.id] ?? null;
|
||||
});
|
||||
|
||||
const hasWashCertificateAttachment = computed(() =>
|
||||
attachmentsFromOrder.value.some((attachment) => isWashCertificateAttachment(attachment))
|
||||
);
|
||||
|
||||
const hasCachedPreviewSource = (attachmentId) => {
|
||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
|
||||
};
|
||||
@@ -992,7 +1093,7 @@ const shouldRetryPreviewRequestWithAuth = (downloadLink) => {
|
||||
|
||||
try {
|
||||
const previewUrl = new URL(downloadLink, window.location.origin);
|
||||
const apiOrigin = new URL(API_URL, window.location.origin).origin;
|
||||
const apiOrigin = new URL(getReleaseRuntimeApiBaseUrl(), window.location.origin).origin;
|
||||
return previewUrl.origin === apiOrigin || previewUrl.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -1529,6 +1630,137 @@ const showCompleteOrderBookingConfirmation = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const showEmailNotificationActionResult = async (requestAction, successKey, errorKey) => {
|
||||
try {
|
||||
await requestAction();
|
||||
await Swal.fire({
|
||||
title: t(successKey),
|
||||
icon: "success",
|
||||
showConfirmButton: false,
|
||||
timer: 2000,
|
||||
heightAuto: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await Swal.fire({
|
||||
title: t("common.error"),
|
||||
text: [t(errorKey), SessionUser.functions.parseErrorMessage?.(error)].filter(Boolean).join(": "),
|
||||
icon: "error",
|
||||
heightAuto: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resendBookingConfirmation = () =>
|
||||
showEmailNotificationActionResult(
|
||||
() => SessionUser.objects.order_bookings.functions.resendBookingConfirmation(props.order_booking_id),
|
||||
"admin.pos.settings_wheel.resend_booking_confirmation_success",
|
||||
"admin.pos.settings_wheel.resend_booking_confirmation_error"
|
||||
);
|
||||
|
||||
const resendBookingCompletionConfirmation = () =>
|
||||
showEmailNotificationActionResult(
|
||||
() => SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation(props.order_booking_id),
|
||||
"admin.pos.settings_wheel.resend_booking_completion_confirmation_success",
|
||||
"admin.pos.settings_wheel.resend_booking_completion_confirmation_error"
|
||||
);
|
||||
|
||||
const resendWashCertificate = () =>
|
||||
showEmailNotificationActionResult(
|
||||
() => SessionUser.objects.orders.functions.resendWashCertificate(props.order_id),
|
||||
"admin.pos.settings_wheel.resend_wash_certificate_success",
|
||||
"admin.pos.settings_wheel.resend_wash_certificate_error"
|
||||
);
|
||||
|
||||
const getAvailableInvoiceCollectionsForCustomer = async (customerNumber) => {
|
||||
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
|
||||
if (!normalizedCustomerNumber) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await SessionUser.request("/collected-invoices", "GET", {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
order: "closed_at:asc",
|
||||
filters: `customer_number:${normalizedCustomerNumber},booked_invoice_id:is_null`,
|
||||
});
|
||||
|
||||
return Array.isArray(response?.data?.data) ? response.data.data : [];
|
||||
};
|
||||
|
||||
const ensureOpenInvoiceCollectionForCustomer = async (customerNumber) => {
|
||||
const collections = await getAvailableInvoiceCollectionsForCustomer(customerNumber);
|
||||
const openCollection = collections.find((collection) => collection?.closed_at === null);
|
||||
const openCollectionId = normalizePositiveInteger(openCollection?.id);
|
||||
if (openCollectionId) {
|
||||
return openCollectionId;
|
||||
}
|
||||
|
||||
const response = await SessionUser.objects.collectedOrderInvoices.add(
|
||||
customerNumber,
|
||||
t("admin.pos.drafts_assignment.new_collection_name"),
|
||||
t("admin.pos.drafts_assignment.new_collection_description"),
|
||||
null
|
||||
);
|
||||
|
||||
return normalizePositiveInteger(response?.data?.data?.id ?? response?.data?.id);
|
||||
};
|
||||
|
||||
const acceptSelfServeWashDraft = async () => {
|
||||
const customerNumber = getSelfServeWashCustomerNumber.value;
|
||||
const orderId = normalizePositiveInteger(props.order_id);
|
||||
if (!customerNumber || !orderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.accept_self_serve_wash"),
|
||||
text: t("admin.pos.settings_wheel.accept_self_serve_wash_confirm", { customerNumber }),
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t("common.confirm"),
|
||||
cancelButtonText: t("common.cancel"),
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const invoiceCollectionId = await ensureOpenInvoiceCollectionForCustomer(customerNumber);
|
||||
if (!invoiceCollectionId) {
|
||||
throw new Error(t("admin.pos.drafts_assignment.invoice_collection_empty"));
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.functions.assignDraftCustomer({
|
||||
order_id: orderId,
|
||||
customer_id: customerNumber,
|
||||
invoice_collection_id: invoiceCollectionId,
|
||||
department_id: normalizePositiveInteger(props.department_id),
|
||||
recalculate_prices: true,
|
||||
});
|
||||
|
||||
await props.refreshFunction();
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: t("admin.pos.settings_wheel.accept_self_serve_wash_success"),
|
||||
timer: 1800,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: "error",
|
||||
title: t("admin.pos.drafts_assignment.error"),
|
||||
text: SessionUser.functions.parseErrorMessage(error) || t("admin.pos.drafts_assignment.error"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const canDeleteOrderBooking = computed(() =>
|
||||
!props.order_id &&
|
||||
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
|
||||
);
|
||||
|
||||
const flatBuiltInMenuSections = computed(() => {
|
||||
const sections = [];
|
||||
|
||||
@@ -1583,7 +1815,7 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
),
|
||||
})
|
||||
: null,
|
||||
!props.order_id
|
||||
canDeleteOrderBooking.value
|
||||
? buildMenuAction("booking-delete", {
|
||||
icon: "fas fa-trash-alt",
|
||||
label: t("admin.pos.settings_wheel.delete_booking"),
|
||||
@@ -1614,6 +1846,16 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
? redirectDepartmentOrderPage(props.order_id, true)
|
||||
: SessionUser.functions.redirectTo.user("/orders/" + props.order_id, true),
|
||||
}),
|
||||
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
|
||||
? canAcceptSelfServeWashDraft.value
|
||||
? buildMenuAction("order-accept-self-serve-wash", {
|
||||
icon: "fas fa-check-circle",
|
||||
label: t("admin.pos.settings_wheel.accept_self_serve_wash"),
|
||||
template: "success",
|
||||
clickAction: acceptSelfServeWashDraft,
|
||||
})
|
||||
: null
|
||||
: null,
|
||||
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
|
||||
? buildMenuAction("order-attach-wash-certificate", {
|
||||
icon: "fas fa-paperclip",
|
||||
@@ -1661,6 +1903,40 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (props.order_booking_id || hasWashCertificateAttachment.value) {
|
||||
const emailNotificationsSection = buildMenuSection(
|
||||
"email-notifications",
|
||||
t("admin.pos.settings_wheel.email_notifications_section"),
|
||||
[
|
||||
props.order_booking_id
|
||||
? buildMenuAction("email-notifications-resend-booking-confirmation", {
|
||||
icon: "fas fa-envelope",
|
||||
label: t("admin.pos.settings_wheel.resend_booking_confirmation"),
|
||||
clickAction: resendBookingConfirmation,
|
||||
})
|
||||
: null,
|
||||
props.order_booking_id && hasWashCertificateAttachment.value
|
||||
? buildMenuAction("email-notifications-resend-booking-completion-confirmation", {
|
||||
icon: "fas fa-envelope-open-text",
|
||||
label: t("admin.pos.settings_wheel.resend_booking_completion_confirmation"),
|
||||
clickAction: resendBookingCompletionConfirmation,
|
||||
})
|
||||
: null,
|
||||
!props.order_booking_id && hasWashCertificateAttachment.value
|
||||
? buildMenuAction("email-notifications-resend-wash-certificate", {
|
||||
icon: "fas fa-file-pdf",
|
||||
label: t("admin.pos.settings_wheel.resend_wash_certificate"),
|
||||
clickAction: resendWashCertificate,
|
||||
})
|
||||
: null,
|
||||
]
|
||||
);
|
||||
|
||||
if (emailNotificationsSection) {
|
||||
sections.push(emailNotificationsSection);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.invoice_collection_id && SessionUser.canAccessSuperUser()) {
|
||||
const invoiceCollectionLinkSection = buildMenuSection(
|
||||
"invoice-collection-link",
|
||||
@@ -1962,10 +2238,10 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (props.reg_1 || props.reg_2) {
|
||||
if (props.reg_1 || props.reg_2 || props.reg_3) {
|
||||
const vehicleSection = buildMenuSection(
|
||||
"vehicle",
|
||||
props.reg_1 && props.reg_2
|
||||
[props.reg_1, props.reg_2, props.reg_3].filter(Boolean).length > 1
|
||||
? SessionUser.objects.vehicles.meta.labels.multiple
|
||||
: SessionUser.objects.vehicles.meta.labels.single,
|
||||
[
|
||||
@@ -1983,6 +2259,13 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true),
|
||||
})
|
||||
: null,
|
||||
props.reg_3 && SessionUser.canAccessSuperUser()
|
||||
? buildMenuAction("vehicle-reg-3", {
|
||||
icon: "fas fa-car",
|
||||
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_3 }),
|
||||
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_3, true),
|
||||
})
|
||||
: null,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -2477,7 +2760,7 @@ const syncDesktopFlyoutPosition = () => {
|
||||
{{ t("admin.pos.attachments_office_preview_unavailable") }}
|
||||
</span>
|
||||
<span v-else-if="activeAttachmentPreviewKind === 'text'" class="action-settings-wheel-attachment-panel__text">
|
||||
{{ activeAttachment.content?.other }}
|
||||
{{ formatAttachmentText(activeAttachment) }}
|
||||
</span>
|
||||
<span v-else class="action-settings-wheel-attachment-panel__text">
|
||||
{{ t("admin.pos.attachments_no_preview") }}
|
||||
@@ -2867,6 +3150,7 @@ const syncDesktopFlyoutPosition = () => {
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.action-settings-wheel-attachment-panel__link {
|
||||
|
||||
@@ -6,6 +6,10 @@ const props = defineProps({
|
||||
icon: String,
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
testId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
template: String // The style of the button (default, danger, success, warning, info, light)
|
||||
});
|
||||
const emit = defineEmits(['selected']);
|
||||
@@ -131,6 +135,7 @@ const getLabelColor = () => {
|
||||
@click.stop.prevent="click"
|
||||
:class="{'is-disabled': isDisabled()}"
|
||||
:disabled="isDisabled()"
|
||||
:data-testid="props.testId || undefined"
|
||||
>
|
||||
<span class="icon">
|
||||
<i :class="getIcon() + ' ' + getIconColor()"></i>
|
||||
|
||||
@@ -415,7 +415,7 @@ const handleShortcutSelection = (event) => {
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
@change="handleShortcutSelection"
|
||||
>
|
||||
<option value="" disabled>Vaelg periode</option>
|
||||
<option value="" disabled>Vælg periode</option>
|
||||
<option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label">
|
||||
{{ shortcut.label }}
|
||||
</option>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { useRouter } from "vue-router";
|
||||
import { getCurrentStep, setDepartment, getOrderId, setStep, setOrderId, searchAndSelectCustomer, loadOrderItems } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {
|
||||
clearActivePosOrderContext,
|
||||
getCurrentStep,
|
||||
setDepartment,
|
||||
getOrderId,
|
||||
setStep,
|
||||
setOrderId,
|
||||
searchAndSelectCustomer,
|
||||
loadOrderItems,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
|
||||
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
|
||||
import PosDepartmentStep3 from "@/components/displays/department/pos/steps/PosDepartmentStep3.vue";
|
||||
@@ -41,20 +50,33 @@ const debugVehicles = () => {
|
||||
}
|
||||
);
|
||||
};
|
||||
setDepartment();
|
||||
|
||||
watch(() => router.currentRoute.value.params.departmentId, (nextDepartmentId) => {
|
||||
if (nextDepartmentId) {
|
||||
setDepartment(nextDepartmentId);
|
||||
}
|
||||
});
|
||||
|
||||
applyPosRouteSearch(window.location.search, {
|
||||
const routeStateHandlers = {
|
||||
setOrderId,
|
||||
loadOrderItems,
|
||||
setStep,
|
||||
searchAndSelectCustomer,
|
||||
});
|
||||
clearActivePosOrderContext,
|
||||
resetMobilePos: () => pos.reset.pos(),
|
||||
};
|
||||
|
||||
const getRouteSearch = (route) => {
|
||||
const fullPath = route?.fullPath || "";
|
||||
const queryIndex = fullPath.indexOf("?");
|
||||
return queryIndex >= 0 ? fullPath.slice(queryIndex) : "";
|
||||
};
|
||||
|
||||
const applyCurrentPosRoute = () => {
|
||||
const currentRoute = router.currentRoute.value;
|
||||
if (currentRoute.params.departmentId) {
|
||||
setDepartment(currentRoute.params.departmentId);
|
||||
} else {
|
||||
setDepartment();
|
||||
}
|
||||
applyPosRouteSearch(getRouteSearch(currentRoute), routeStateHandlers);
|
||||
};
|
||||
|
||||
watch(() => router.currentRoute.value.fullPath, applyCurrentPosRoute, { immediate: true });
|
||||
|
||||
/** Define the createOrder function */
|
||||
</script>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
selectPreferredStripeTerminalReaderId,
|
||||
STRIPE_TERMINAL_STATUS,
|
||||
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
||||
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
|
||||
|
||||
const POLLING_INTERVAL_MS = 5000;
|
||||
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
|
||||
@@ -87,29 +88,6 @@ const selectedTaxRate = ref(1);
|
||||
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
||||
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
|
||||
|
||||
const normalizeStripeInvoice = (value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const invoiceId = value.invoice_id || value.id || null;
|
||||
if (!invoiceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id ?? props.order_id,
|
||||
invoice_id: invoiceId,
|
||||
customer_id: value.customer_id ?? null,
|
||||
url: value.url || value.hosted_invoice_url || null,
|
||||
created_at: value.created_at || null,
|
||||
paid: Boolean(value.paid),
|
||||
status: value.status || 'unknown',
|
||||
amount_due: Number(value.amount_due ?? 0),
|
||||
amount_paid: Number(value.amount_paid ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
|
||||
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
|
||||
const isStripeEmailInvoiceTerminalState = computed(() => {
|
||||
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
|
||||
|
||||
try {
|
||||
const response = await getOrder(props.order_id, true);
|
||||
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders);
|
||||
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
|
||||
stripeInvoice.value = nextInvoice;
|
||||
if (nextInvoice) {
|
||||
emailPanelState.value = 'tracking';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const STRIPE_INVOICE_PAID_STATUS = 'paid';
|
||||
|
||||
export const parseStripeInvoicePaidFlag = (value) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
if (['true', '1'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (['false', '0', ''].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value, fallback = 0) => {
|
||||
const parsedValue = Number(value ?? fallback);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||
};
|
||||
|
||||
export const normalizeStripeInvoice = (value, fallbackOrderId = null) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const invoiceId = value.invoice_id || value.id || null;
|
||||
if (!invoiceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = String(value.status || 'unknown').toLowerCase();
|
||||
const amountDue = toFiniteNumber(value.amount_due);
|
||||
const amountPaid = toFiniteNumber(value.amount_paid);
|
||||
const hasCoveredAmountDue = amountDue <= 0 || amountPaid >= amountDue;
|
||||
const isPaid = status === STRIPE_INVOICE_PAID_STATUS
|
||||
&& parseStripeInvoicePaidFlag(value.paid)
|
||||
&& hasCoveredAmountDue;
|
||||
|
||||
return {
|
||||
id: value.id ?? fallbackOrderId,
|
||||
invoice_id: invoiceId,
|
||||
customer_id: value.customer_id ?? null,
|
||||
url: value.url || value.hosted_invoice_url || null,
|
||||
created_at: value.created_at || null,
|
||||
paid: isPaid,
|
||||
status,
|
||||
amount_due: amountDue,
|
||||
amount_paid: amountPaid,
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,16 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
||||
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
|
||||
const safePreviewBlobTypesByKind = {
|
||||
image: {
|
||||
fallback: "image/png",
|
||||
allowed: new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]),
|
||||
},
|
||||
document: {
|
||||
fallback: "application/pdf",
|
||||
allowed: new Set(["application/pdf"]),
|
||||
},
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
|
||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
|
||||
};
|
||||
|
||||
const createEmbeddablePreviewUrl = async (downloadLink) => {
|
||||
const createSafePreviewBlob = (fileBlob, previewKind) => {
|
||||
const previewBlobTypes = safePreviewBlobTypesByKind[previewKind];
|
||||
if (!previewBlobTypes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedBlobType = String(fileBlob.type || "").toLowerCase();
|
||||
const safeBlobType = previewBlobTypes.allowed.has(normalizedBlobType)
|
||||
? normalizedBlobType
|
||||
: previewBlobTypes.fallback;
|
||||
|
||||
return new Blob([fileBlob], { type: safeBlobType });
|
||||
};
|
||||
|
||||
const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => {
|
||||
if (!downloadLink) {
|
||||
return null;
|
||||
}
|
||||
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(fileBlob);
|
||||
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
|
||||
if (!safePreviewBlob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(safePreviewBlob);
|
||||
generatedObjectUrls.add(objectUrl);
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
|
||||
attachment.id,
|
||||
false
|
||||
);
|
||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
|
||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
|
||||
previewSourcesById.value = {
|
||||
...previewSourcesById.value,
|
||||
[attachment.id]: previewSource,
|
||||
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
|
||||
:src="activePreviewSource"
|
||||
class="order-attachments-preview-panel__document"
|
||||
title="Attachment preview"
|
||||
sandbox
|
||||
></iframe>
|
||||
<a
|
||||
v-else-if="activePreviewKind === 'link'"
|
||||
|
||||
@@ -1260,7 +1260,12 @@ const formatCashierName = (order) => {
|
||||
v-bind:user_id="order.user_id"
|
||||
v-bind:order_id="order.id"
|
||||
v-bind:invoice_collection_id="order.invoice_collection_id"
|
||||
v-bind:customer_number="order.customer_id"
|
||||
v-bind:department_id="order.department_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
v-bind:reg_2="order.reg_2"
|
||||
v-bind:reg_3="order.reg_3"
|
||||
v-bind:order_booking_id="order.booking_id"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
@flag-created="emitFlagCreated"
|
||||
@@ -1501,7 +1506,12 @@ const formatCashierName = (order) => {
|
||||
v-bind:user_id="order.user_id"
|
||||
v-bind:order_id="order.id"
|
||||
v-bind:invoice_collection_id="order.invoice_collection_id"
|
||||
v-bind:customer_number="order.customer_id"
|
||||
v-bind:department_id="order.department_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
v-bind:reg_2="order.reg_2"
|
||||
v-bind:reg_3="order.reg_3"
|
||||
v-bind:order_booking_id="order.booking_id"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
@flag-created="emitFlagCreated"
|
||||
@@ -1992,6 +2002,7 @@ const formatCashierName = (order) => {
|
||||
v-bind:user_id="selectedOrderForActionsMenu.user_id"
|
||||
v-bind:order_id="selectedOrderForActionsMenu.id"
|
||||
v-bind:invoice_collection_id="selectedOrderForActionsMenu.invoice_collection_id"
|
||||
v-bind:customer_number="selectedOrderForActionsMenu.customer_id"
|
||||
v-bind:reg_1="selectedOrderForActionsMenu.reg_1"
|
||||
v-bind:reg_2="selectedOrderForActionsMenu.reg_2"
|
||||
v-bind:reg_3="selectedOrderForActionsMenu.reg_3"
|
||||
|
||||
@@ -92,6 +92,7 @@ const duplicateDetailsExpanded = ref(false);
|
||||
const pendingNextResolution = ref(false);
|
||||
const isDesktopLastWashCopying = ref(false);
|
||||
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
|
||||
let focusOnReg1TimeoutId = null;
|
||||
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
|
||||
|
||||
const setTab = (tab) => {
|
||||
@@ -117,7 +118,16 @@ watch(
|
||||
);
|
||||
|
||||
const focusOnReg1 = () => {
|
||||
setTimeout(() => {
|
||||
if (focusOnReg1TimeoutId !== null) {
|
||||
clearTimeout(focusOnReg1TimeoutId);
|
||||
}
|
||||
|
||||
focusOnReg1TimeoutId = setTimeout(() => {
|
||||
focusOnReg1TimeoutId = null;
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg1Input = document.getElementById("reg_1");
|
||||
if (reg1Input) {
|
||||
reg1Input.focus();
|
||||
@@ -267,12 +277,8 @@ const bookingSelectionObjects = computed(() => {
|
||||
const contentSegments = [
|
||||
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
|
||||
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
|
||||
`${t("common.reference")}: ${
|
||||
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
|
||||
}`,
|
||||
`${t("common.services")}: ${
|
||||
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
|
||||
}`,
|
||||
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
|
||||
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -297,7 +303,7 @@ const bookingSelectionObjects = computed(() => {
|
||||
const duplicateDetailsObjects = computed(() => {
|
||||
return duplicateOrders.value.map((order) => ({
|
||||
id: Number(order.id),
|
||||
label: `${t("common.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
|
||||
label: `${t("admin.pos.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
|
||||
content: getDuplicateOrderContent(order),
|
||||
buttons: [
|
||||
{
|
||||
@@ -660,7 +666,9 @@ const handleDesktopLastWashCopy = async (payload = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || []);
|
||||
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || [], {
|
||||
sourceReference: payload?.order?.reference,
|
||||
});
|
||||
if (!didCopy) {
|
||||
return;
|
||||
}
|
||||
@@ -679,6 +687,10 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (focusOnReg1TimeoutId !== null) {
|
||||
clearTimeout(focusOnReg1TimeoutId);
|
||||
focusOnReg1TimeoutId = null;
|
||||
}
|
||||
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
||||
});
|
||||
|
||||
@@ -750,11 +762,7 @@ watch(
|
||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||
<Cancel tabindex="2" class="is-fullwidth" />
|
||||
</ButtonsBox>
|
||||
<div
|
||||
v-if="shouldShowActionRailControls"
|
||||
class="pos-shell-actions__rail"
|
||||
data-testid="pos-step-1-action-rail"
|
||||
>
|
||||
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
|
||||
<PosDesktopDuplicateWarning
|
||||
v-if="shouldShowDuplicateWarningInActionRail"
|
||||
:title="t('admin.pos.warning')"
|
||||
|
||||
@@ -190,7 +190,7 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="pos-step-one-insight-card__section">
|
||||
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-last-wash-section">
|
||||
<div class="pos-step-one-insight-card__section-header">
|
||||
<h4 class="pos-step-one-insight-card__section-title">Indhold</h4>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="pos-step-one-insight-card__section">
|
||||
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-vehicle-summary-section">
|
||||
<div class="pos-step-one-insight-card__section-header">
|
||||
<h4 class="pos-step-one-insight-card__section-title">Ydelse og tilvalg</h4>
|
||||
</div>
|
||||
|
||||
+60
-5
@@ -14,7 +14,7 @@ type attachment = {
|
||||
image: string | null;
|
||||
document: string | null;
|
||||
relation: string | null;
|
||||
other: string | null;
|
||||
other: unknown;
|
||||
src: string | null; // For document preview (e.g., PDF URL) // THIS IS NEVER STORED, JUST FOR PREVIEW PURPOSES
|
||||
};
|
||||
created_at: string;
|
||||
@@ -22,6 +22,8 @@ type attachment = {
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
const SELF_SERVE_WASH_ATTACHMENT_TYPE = 'SELF_SERVE_WASH';
|
||||
|
||||
const getAttachmentContent = (attachmentEntry: attachment) => {
|
||||
if (!attachmentEntry.content) {
|
||||
return {
|
||||
@@ -43,7 +45,19 @@ const getAttachmentContent = (attachmentEntry: attachment) => {
|
||||
};
|
||||
|
||||
const getAttachmentOtherText = (attachmentEntry: attachment) => {
|
||||
return getAttachmentContent(attachmentEntry).other || '';
|
||||
const other = getAttachmentContent(attachmentEntry).other;
|
||||
if (typeof other === 'string') {
|
||||
return other;
|
||||
}
|
||||
|
||||
if (isSelfServeWashAttachment(attachmentEntry)) {
|
||||
const customerNumber = getSelfServeWashPayload(attachmentEntry)?.customer_number;
|
||||
return customerNumber
|
||||
? `${t('admin.pos.settings_wheel.self_serve_wash_attachment')} #${customerNumber}`
|
||||
: t('admin.pos.settings_wheel.self_serve_wash_attachment');
|
||||
}
|
||||
|
||||
return other && typeof other === 'object' ? JSON.stringify(other) : '';
|
||||
};
|
||||
const props = defineProps({
|
||||
attachments: {
|
||||
@@ -79,6 +93,26 @@ const determineAttachmentType = (attachment: attachment): 'image' | 'document' |
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const getSelfServeWashPayload = (attachmentEntry: attachment): Record<string, any> | null => {
|
||||
const other = getAttachmentContent(attachmentEntry).other;
|
||||
return other && typeof other === 'object' && (other as Record<string, any>).type === SELF_SERVE_WASH_ATTACHMENT_TYPE
|
||||
? other as Record<string, any>
|
||||
: null;
|
||||
};
|
||||
|
||||
const isSelfServeWashAttachment = (attachmentEntry: attachment): boolean => {
|
||||
return getSelfServeWashPayload(attachmentEntry) !== null;
|
||||
};
|
||||
|
||||
const formatSelfServeDriver = (payload: Record<string, any>): string => {
|
||||
return payload.subuser?.name || payload.subuser?.username || (payload.subuser_id ? `#${payload.subuser_id}` : '-');
|
||||
};
|
||||
|
||||
const formatElapsedMinutes = (seconds: unknown): string => {
|
||||
const parsed = Number(seconds);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? `${Math.ceil(parsed / 60)} min` : '-';
|
||||
};
|
||||
|
||||
const getAttachmentTypeIcon = (attachment: attachment): string => {
|
||||
const type = determineAttachmentType(attachment);
|
||||
switch (type) {
|
||||
@@ -309,14 +343,35 @@ const onClickAttachWashCertificate = () => {
|
||||
</template>
|
||||
<!-- OTHER PREVIEW -->
|
||||
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
|
||||
<template v-if="isSelfServeWashAttachment(attachment)">
|
||||
<div class="content is-size-7">
|
||||
<p class="has-text-weight-semibold">{{ t('admin.pos.settings_wheel.self_serve_wash_attachment') }}</p>
|
||||
<p>
|
||||
<strong>{{ t('admin.pos.settings_wheel.self_serve_customer') }}:</strong>
|
||||
#{{ getSelfServeWashPayload(attachment)?.customer_number || '-' }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ t('admin.pos.settings_wheel.self_serve_driver') }}:</strong>
|
||||
{{ formatSelfServeDriver(getSelfServeWashPayload(attachment) || {}) }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ t('pos.license_plate') }}:</strong>
|
||||
{{ getSelfServeWashPayload(attachment)?.license_plate || '-' }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ t('admin.pos.settings_wheel.self_serve_elapsed') }}:</strong>
|
||||
{{ formatElapsedMinutes(getSelfServeWashPayload(attachment)?.elapsed_wash_time_seconds) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- If the other type is a URL, you can create a link -->
|
||||
<template v-if="getAttachmentContent(attachment).other.startsWith('http')">
|
||||
<a :href="getAttachmentContent(attachment).other" target="_blank" rel="noopener noreferrer">
|
||||
<template v-else-if="typeof getAttachmentContent(attachment).other === 'string' && getAttachmentContent(attachment).other.startsWith('http')">
|
||||
<a :href="String(getAttachmentContent(attachment).other)" target="_blank" rel="noopener noreferrer">
|
||||
{{ getAttachmentContent(attachment).other }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ getAttachmentContent(attachment).other }}</span>
|
||||
<span>{{ getAttachmentOtherText(attachment) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<!-- NO PREVIEW -->
|
||||
|
||||
+14
-21
@@ -1,34 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
reset_all_values,
|
||||
customer_name,
|
||||
nextStep,
|
||||
searchAndSelectCustomer,
|
||||
isCustomerSelected,
|
||||
order_id,
|
||||
customer_id,
|
||||
step,
|
||||
reg_1,
|
||||
reg_2,
|
||||
reg_3,
|
||||
reference,
|
||||
order_notes,
|
||||
setDepartment,
|
||||
getDepartment,
|
||||
getStoredPosOrderId,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
||||
import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const toPositiveInteger = (value: unknown) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const onClickClearAll = async () => {
|
||||
// 1. Delete the order (If any)
|
||||
// Check localstorage for pos_order_id
|
||||
if (localStorage.getItem("pos_order_id")) {
|
||||
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
|
||||
}
|
||||
if (order_id.value) {
|
||||
const storedOrderId = getStoredPosOrderId();
|
||||
const currentOrderId = toPositiveInteger(order_id.value);
|
||||
const activeDraftOrderId = storedOrderId && currentOrderId === storedOrderId ? storedOrderId : null;
|
||||
|
||||
// 1. Delete only the active mobile draft order, never a historical route-loaded order.
|
||||
if (activeDraftOrderId) {
|
||||
try {
|
||||
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
|
||||
order_id.value = activeDraftOrderId;
|
||||
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(activeDraftOrderId);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
+2
-9
@@ -299,14 +299,12 @@ const getSelectedBookingId = () => {
|
||||
|
||||
const completeStep2Order = async ({
|
||||
bookingSafetySeal = null,
|
||||
markOrderCompleted = false,
|
||||
}: {
|
||||
bookingSafetySeal?: string | null;
|
||||
markOrderCompleted?: boolean;
|
||||
} = {}) => {
|
||||
await finalizeCurrentMobileOrder({
|
||||
bookingSafetySeal,
|
||||
markOrderCompleted,
|
||||
markOrderCompleted: true,
|
||||
});
|
||||
popups.select("completed_transaction", {
|
||||
message: `Order #${order_id.value} successfully created.`,
|
||||
@@ -327,14 +325,12 @@ const step2 = async () => {
|
||||
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
||||
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
|
||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
||||
|
||||
try {
|
||||
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
|
||||
syncMobileSafetySealState(resolvedSafetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: resolvedSafetySeal,
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -344,7 +340,6 @@ const step2 = async () => {
|
||||
syncMobileSafetySealState(safetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: String(safetySeal ?? ""),
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -358,9 +353,7 @@ const step2 = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await completeStep2Order({
|
||||
markOrderCompleted: shouldMarkOrderAsCompleted,
|
||||
});
|
||||
await completeStep2Order();
|
||||
} catch (error: any) {
|
||||
errors.value.push(error);
|
||||
console.warn("An error occurred while completing the mobile order:", error);
|
||||
|
||||
+7
-1
@@ -9,6 +9,9 @@ const product = props.product;
|
||||
const note = ref(product.notes || "");
|
||||
watch(note, (newNote) => {
|
||||
product.notes = newNote;
|
||||
if (props) {
|
||||
props.validationMessage = "";
|
||||
}
|
||||
// If the note is empty, remove it from the product
|
||||
if (newNote === "") {
|
||||
delete product.notes;
|
||||
@@ -28,6 +31,9 @@ watch(note, (newNote) => {
|
||||
placeholder="Indtast note"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="props?.validationMessage" class="help is-danger mt-2">
|
||||
{{ props.validationMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -125,4 +131,4 @@ input.is-searched {
|
||||
flex-grow: 0;
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+9
-1
@@ -788,7 +788,15 @@ const promptForNotesIfRequired = (product: PosProduct, callback: (notes: string)
|
||||
label: "Bekræft",
|
||||
description: "Bekræft noten og fortsæt",
|
||||
onClick: () => {
|
||||
callback(popups.get()?.props?.product?.notes || "");
|
||||
const activePopup = popups.get();
|
||||
const note = String(activePopup?.props?.product?.notes || "").trim();
|
||||
if (!note) {
|
||||
if (activePopup?.props) {
|
||||
activePopup.props.validationMessage = "Note er påkrævet for dette produkt";
|
||||
}
|
||||
return;
|
||||
}
|
||||
callback(note);
|
||||
clearPopup();
|
||||
},
|
||||
color: "primary",
|
||||
|
||||
@@ -3,6 +3,45 @@ export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const CACHE_PREFIX = "xlvask-usage-amount:";
|
||||
const memoryCache = new Map();
|
||||
|
||||
const getStorageValue = (key) => {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return "";
|
||||
}
|
||||
return window.localStorage.getItem(key) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const hashCacheScopePart = (value) => {
|
||||
let hash = 5381;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
};
|
||||
|
||||
const getAuthenticatedCacheScope = () => {
|
||||
const token = getStorageValue("token");
|
||||
if (!token) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
hashCacheScopePart(token),
|
||||
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
|
||||
getStorageValue("selected_customer_number"),
|
||||
]
|
||||
.map((part) => encodeURIComponent(String(part ?? "")))
|
||||
.join("|");
|
||||
};
|
||||
|
||||
const getScopedCacheKey = (cacheKey) => {
|
||||
const scope = getAuthenticatedCacheScope();
|
||||
return scope ? `${scope}:${cacheKey}` : "";
|
||||
};
|
||||
|
||||
const safeSessionStorage = () => {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.sessionStorage) {
|
||||
@@ -15,9 +54,7 @@ const safeSessionStorage = () => {
|
||||
};
|
||||
|
||||
const normalizeUsageLogId = (objectOrId) => {
|
||||
const value = typeof objectOrId === "object"
|
||||
? objectOrId?.usage_log_id ?? objectOrId?.id
|
||||
: objectOrId;
|
||||
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
};
|
||||
@@ -34,17 +71,18 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
|
||||
|
||||
export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
if (!cacheKey) {
|
||||
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||
if (!scopedCacheKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryEntry = memoryCache.get(cacheKey);
|
||||
const memoryEntry = memoryCache.get(scopedCacheKey);
|
||||
if (isFreshEntry(memoryEntry)) {
|
||||
return memoryEntry.data;
|
||||
}
|
||||
|
||||
if (memoryEntry) {
|
||||
memoryCache.delete(cacheKey);
|
||||
memoryCache.delete(scopedCacheKey);
|
||||
}
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
@@ -52,14 +90,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
|
||||
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||
if (!isFreshEntry(parsed)) {
|
||||
storage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
memoryCache.set(cacheKey, parsed);
|
||||
memoryCache.set(scopedCacheKey, parsed);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
storage.removeItem(storageKey);
|
||||
@@ -69,7 +107,8 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
|
||||
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
if (!cacheKey) {
|
||||
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||
if (!scopedCacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +116,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
storedAt: Date.now(),
|
||||
data,
|
||||
};
|
||||
memoryCache.set(cacheKey, entry);
|
||||
memoryCache.set(scopedCacheKey, entry);
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
@@ -85,7 +124,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
||||
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||
} catch {
|
||||
// Best-effort cache. Quota errors should not block XL Vask usage rows.
|
||||
}
|
||||
@@ -94,11 +133,12 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
|
||||
const storage = safeSessionStorage();
|
||||
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||
|
||||
if (cacheKey) {
|
||||
memoryCache.delete(cacheKey);
|
||||
if (scopedCacheKey) {
|
||||
memoryCache.delete(scopedCacheKey);
|
||||
try {
|
||||
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
|
||||
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
|
||||
} catch {
|
||||
// Best-effort cache cleanup.
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
|
||||
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
|
||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||
import { API_URL } from "@/config";
|
||||
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||
import {
|
||||
buildSelfServeDynamicImageUrl,
|
||||
getSelfServeDynamicImageButtonsToPress,
|
||||
getSelfServeDynamicImageThumbPosition,
|
||||
} from "@/services/selfServeDynamicImage.js";
|
||||
|
||||
const { t: $t } = useI18n();
|
||||
|
||||
@@ -138,36 +141,9 @@ const canClearAnswers = computed(() => (
|
||||
));
|
||||
const hideDynamicImage = ref(false);
|
||||
|
||||
const parseNonNegativeInt = (value) => {
|
||||
const parsed = parseInt(value);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const dynamicImageButtons = computed(() => getSelfServeDynamicImageButtonsToPress(activeTasks.value));
|
||||
|
||||
const dynamicImageButtons = computed(() => {
|
||||
const buttonIds = new Map();
|
||||
|
||||
activeTasks.value.forEach((task) => {
|
||||
normalizeSelfServeTaskButtons(task?.buttons).forEach((button) => {
|
||||
buttonIds.set(`${typeof button}:${button}`, button);
|
||||
});
|
||||
});
|
||||
|
||||
return [...buttonIds.values()];
|
||||
});
|
||||
|
||||
const dynamicImageThumbPosition = computed(() => {
|
||||
for (const task of activeTasks.value) {
|
||||
const parsedPosition = parseNonNegativeInt(task?.dynamic_images_vehicle_type);
|
||||
if (parsedPosition !== null) {
|
||||
return parsedPosition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
const dynamicImageThumbPosition = computed(() => getSelfServeDynamicImageThumbPosition(activeTasks.value));
|
||||
|
||||
const hasDynamicImageContext = computed(() => (
|
||||
dynamicImageButtons.value.length > 0 || dynamicImageThumbPosition.value !== null
|
||||
@@ -180,23 +156,15 @@ const dynamicImageUrl = computed(() => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
department: String(departmentId),
|
||||
lane: String(laneId),
|
||||
current_step: "0",
|
||||
buttons: JSON.stringify(dynamicImageButtons.value),
|
||||
});
|
||||
|
||||
const selectedVehicleType = parseInt(selectedVehicleTypeId.value);
|
||||
if (!Number.isNaN(selectedVehicleType) && selectedVehicleType > 0) {
|
||||
params.set("vehicle_type", String(selectedVehicleType));
|
||||
}
|
||||
|
||||
if (dynamicImageThumbPosition.value !== null) {
|
||||
params.set("thumb_position", String(dynamicImageThumbPosition.value));
|
||||
}
|
||||
|
||||
return `${API_URL}/department/lanes/dynamic-image?${params.toString()}`;
|
||||
return buildSelfServeDynamicImageUrl({
|
||||
departmentId,
|
||||
laneId,
|
||||
buttons: dynamicImageButtons.value,
|
||||
currentStep: 0,
|
||||
vehicleTypeId: !Number.isNaN(selectedVehicleType) && selectedVehicleType > 0 ? selectedVehicleType : null,
|
||||
thumbPosition: dynamicImageThumbPosition.value,
|
||||
});
|
||||
});
|
||||
|
||||
const displayedDynamicImageUrl = computed(() => (
|
||||
@@ -261,7 +229,7 @@ const clearAnswers = async () => {
|
||||
|
||||
const confirmation = await Swal.fire({
|
||||
title: "Ryd besvarelser?",
|
||||
text: `Registreringsnummer ${normalizedReg.value} pa bane ${selectedLaneId.value} bliver ryddet.`,
|
||||
text: `Registreringsnummer ${normalizedReg.value} på bane ${selectedLaneId.value} bliver ryddet.`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Ja, ryd besvarelser",
|
||||
@@ -316,7 +284,7 @@ watch(dynamicImageUrl, () => {
|
||||
<div class="modal-background" @click="closeModal"></div>
|
||||
<div class="modal-card" style="width: 95%; max-width: 1200px;">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Self-serve preview</p>
|
||||
<p class="modal-card-title">Forhåndsvisning af selvvask</p>
|
||||
<button class="delete" aria-label="close" @click="closeModal"></button>
|
||||
</header>
|
||||
|
||||
@@ -333,7 +301,7 @@ watch(dynamicImageUrl, () => {
|
||||
<label class="label">Bane</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="selectedLaneId" data-testid="self-serve-try-lane">
|
||||
<option :value="null">Vaelg bane</option>
|
||||
<option :value="null">Vælg bane</option>
|
||||
<option v-for="entry in availableLanes" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }} (ID: {{ entry.id }})
|
||||
</option>
|
||||
@@ -345,7 +313,7 @@ watch(dynamicImageUrl, () => {
|
||||
<input class="input" :value="selectedLaneId" type="text" disabled>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<label class="label">Koretojstype</label>
|
||||
<label class="label">Køretøjstype</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="selectedVehicleTypeId" data-testid="self-serve-try-vehicle-type">
|
||||
<option :value="null">Auto (fra registreringsnummer)</option>
|
||||
@@ -357,7 +325,7 @@ watch(dynamicImageUrl, () => {
|
||||
</div>
|
||||
<div class="column is-3 is-flex is-align-items-flex-end">
|
||||
<button class="button is-link is-fullwidth" data-testid="self-serve-try-refresh" :class="{ 'is-loading': loading }" @click="refresh">
|
||||
Hent preview
|
||||
Hent forhåndsvisning
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,10 +335,10 @@ watch(dynamicImageUrl, () => {
|
||||
Tilladt: {{ allowed ? "Ja" : "Nej" }}
|
||||
</span>
|
||||
<span class="tag" :class="machineAvailable ? 'is-success' : 'is-light'">
|
||||
Maskine tilgaengelig: {{ machineAvailable ? "Ja" : "Nej" }}
|
||||
Maskine tilgængelig: {{ machineAvailable ? "Ja" : "Nej" }}
|
||||
</span>
|
||||
<span class="tag" :class="allDisplayQuestionsAnswered ? 'is-success' : 'is-warning'">
|
||||
Alle synlige sporgsmal besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }}
|
||||
Alle synlige spørgsmål besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }}
|
||||
</span>
|
||||
<span v-if="session" class="tag is-info">
|
||||
Session: {{ session.status }} (#{{ session.id }})
|
||||
@@ -379,10 +347,10 @@ watch(dynamicImageUrl, () => {
|
||||
Maskintype: {{ machineType.name }}
|
||||
</span>
|
||||
<span v-if="lane" class="tag is-light">
|
||||
Lane: {{ lane.name || lane.id }}
|
||||
Bane: {{ lane.name || lane.id }}
|
||||
</span>
|
||||
<span v-if="configVersionId" class="tag is-dark">
|
||||
Config version: #{{ configVersionId }}
|
||||
Konfigurationsversion: #{{ configVersionId }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -396,10 +364,10 @@ watch(dynamicImageUrl, () => {
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" style="height: 100%">
|
||||
<h4 class="title is-5">Sporgsmal</h4>
|
||||
<h4 class="title is-5">Spørgsmål</h4>
|
||||
|
||||
<div v-if="displayQuestions.length === 0" class="notification is-success is-light">
|
||||
<p>Ingen synlige sporgsmal for denne preview.</p>
|
||||
<p>Ingen synlige spørgsmål for denne forhåndsvisning.</p>
|
||||
</div>
|
||||
|
||||
<SelfServeQuestionCards
|
||||
@@ -407,18 +375,18 @@ watch(dynamicImageUrl, () => {
|
||||
:answers="answers"
|
||||
@answer-question="submitAnswer"
|
||||
/>
|
||||
<p v-if="questions.length === 0" class="has-text-centered is-italic">Ingen sporgsmal fundet.</p>
|
||||
<p v-if="questions.length === 0" class="has-text-centered is-italic">Ingen spørgsmål fundet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" style="height: 100%">
|
||||
<h4 class="title is-5">Tasks og session</h4>
|
||||
<h4 class="title is-5">Opgaver og session</h4>
|
||||
|
||||
<div v-if="displayedDynamicImageUrl" class="mb-4">
|
||||
<img
|
||||
:src="displayedDynamicImageUrl"
|
||||
alt="Machine status preview"
|
||||
alt="Forhåndsvisning af maskinstatus"
|
||||
data-testid="self-serve-try-dynamic-image"
|
||||
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
|
||||
@error="onDynamicImageError"
|
||||
@@ -433,11 +401,11 @@ watch(dynamicImageUrl, () => {
|
||||
@download-attachment="downloadAttachment"
|
||||
/>
|
||||
|
||||
<p v-if="activeTasks.length === 0" class="is-italic">Ingen aktive tasks.</p>
|
||||
<p v-if="activeTasks.length === 0" class="is-italic">Ingen aktive opgaver.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h5 class="subtitle is-6">Seneste haendelser</h5>
|
||||
<h5 class="subtitle is-6">Seneste hændelser</h5>
|
||||
<ul>
|
||||
<li v-for="event in events" :key="event.id" class="mb-2">
|
||||
<strong>{{ event.type }}</strong>
|
||||
@@ -448,16 +416,16 @@ watch(dynamicImageUrl, () => {
|
||||
|
||||
<hr />
|
||||
|
||||
<h5 class="subtitle is-6">Evaluation trace</h5>
|
||||
<h5 class="subtitle is-6">Evalueringsspor</h5>
|
||||
<div v-if="evaluationTrace" class="content is-small">
|
||||
<pre>{{ JSON.stringify(evaluationTrace, null, 2) }}</pre>
|
||||
</div>
|
||||
<p v-else class="is-italic">Ingen trace-data returneret.</p>
|
||||
<p v-else class="is-italic">Ingen sporingsdata returneret.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between mb-2">
|
||||
<h5 class="subtitle is-6 mb-0">Besvarede sporgsmal</h5>
|
||||
<h5 class="subtitle is-6 mb-0">Besvarede spørgsmål</h5>
|
||||
<button
|
||||
class="button is-small is-light"
|
||||
data-testid="self-serve-try-clear-answers"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
|
||||
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
|
||||
const { search, metaSearch, isLoading, loadList } = paginatedList;
|
||||
@@ -13,10 +13,15 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const canExport = computed(() => typeof paginatedList?.exportToExcel === 'function');
|
||||
const canExport = computed(() => typeof paginatedList?.exportToExcel === "function");
|
||||
const isExporting = computed(() => Boolean(paginatedList?.isExporting?.value));
|
||||
const resolvedSearchPlaceholder = computed(() => props.searchPlaceholder || t("global.search_placeholder"));
|
||||
const isActionDropdownOpen = ref(false);
|
||||
const actionDropdownRef = ref<HTMLElement | null>(null);
|
||||
|
||||
@@ -61,11 +66,11 @@ const handleDocumentClick = (event: MouseEvent) => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -74,12 +79,12 @@ onBeforeUnmount(() => {
|
||||
<div class="columns is-vcentered is-multiline pagination-general-search-reload">
|
||||
<div v-if="!props.hideSearch" class="column pagination-general-search-reload__search-column">
|
||||
<input
|
||||
data-testid="pagination-search-input"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('global.search_placeholder')"
|
||||
v-model="metaSearch"
|
||||
@input="search($event.target.value)"
|
||||
data-testid="pagination-search-input"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="resolvedSearchPlaceholder"
|
||||
v-model="metaSearch"
|
||||
@input="search($event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="column is-narrow pagination-general-search-reload__buttons-column" v-if="$slots.buttons">
|
||||
@@ -134,7 +139,7 @@ onBeforeUnmount(() => {
|
||||
@click.prevent="handleExportExcel"
|
||||
>
|
||||
<i class="fas fa-file-excel" aria-hidden="true"></i>
|
||||
<span>{{ t('pagination.download_excel') }}</span>
|
||||
<span>{{ t("pagination.download_excel") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue';
|
||||
import { ref, inject } from "vue";
|
||||
import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
|
||||
const {
|
||||
isLoading,
|
||||
loadList,
|
||||
metaCurrentPage,
|
||||
metaItemsPerPage,
|
||||
metaTotalItems,
|
||||
setMetaItemsPerPage,
|
||||
setPage,
|
||||
} = paginatedList;
|
||||
const { isLoading, loadList, metaCurrentPage, metaItemsPerPage, metaTotalItems, setMetaItemsPerPage, setPage } =
|
||||
paginatedList;
|
||||
|
||||
import PaginationDisplayGeneralSearchReload
|
||||
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
|
||||
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
|
||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
|
||||
@@ -31,6 +23,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
hidePagination: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -48,6 +44,7 @@ const isSmall = ref(window.innerWidth < 1024);
|
||||
<PaginationDisplayGeneralSearchReload
|
||||
v-if="!props.hideSearch || $slots.buttons"
|
||||
:hide-search="props.hideSearch"
|
||||
:search-placeholder="props.searchPlaceholder"
|
||||
>
|
||||
<template #buttons="{ loadList }" v-if="$slots.buttons">
|
||||
<slot name="buttons" :loadList="loadList"></slot>
|
||||
@@ -66,7 +63,7 @@ const isSmall = ref(window.innerWidth < 1024);
|
||||
<template #paginationColumns>
|
||||
<slot name="paginationDisplayFiltersElement"></slot>
|
||||
<slot name="leftPaginationColumns"></slot>
|
||||
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
|
||||
<div class="column is-auto-fill my-3" v-if="!isSmall" />
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 p-1 m-0"></div>
|
||||
<slot name="rightPaginationColumns"></slot>
|
||||
@@ -89,5 +86,4 @@ const isSmall = ref(window.innerWidth < 1024);
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
+29
-46
@@ -1,30 +1,30 @@
|
||||
<script setup>
|
||||
import { provide, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||
import CustomerComplaintsTable from "@/components/displays/superuser/tables/customerComplaintsTable.vue";
|
||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const props = defineProps(["hideSearch", "autoLoad"]);
|
||||
const props = defineProps({
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autoLoad: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
const paginatedList = usePaginatedList();
|
||||
provide(PaginatedListKey, paginatedList);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
list,
|
||||
loadList,
|
||||
metaCurrentPage,
|
||||
metaItemsPerPage,
|
||||
metaTotalItems,
|
||||
setEndpoint,
|
||||
setMetaItemsPerPage,
|
||||
setPage,
|
||||
search,
|
||||
setFilter,
|
||||
setOrder,
|
||||
hideSearchField,
|
||||
@@ -54,6 +54,11 @@ const onDepartmentFilterChange = (value) => {
|
||||
setFilter("department_id", parsedDepartmentId, true);
|
||||
};
|
||||
|
||||
const onOrderDirectionChange = (value) => {
|
||||
setOrder("created_at", value);
|
||||
loadList();
|
||||
};
|
||||
|
||||
setEndpoint("/departments/daily-reports/complaints", false);
|
||||
setOrder("created_at", "desc");
|
||||
|
||||
@@ -69,22 +74,15 @@ if (props.autoLoad) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-if="!hideSearchField"
|
||||
class="input"
|
||||
data-testid="superuser-complaints-search"
|
||||
type="text"
|
||||
:placeholder="t('superuser.pages.complaints.search_placeholder')"
|
||||
@input="search($event.target.value)"
|
||||
/>
|
||||
|
||||
<PaginationDisplay
|
||||
:metaItemsPerPage="metaItemsPerPage"
|
||||
:loadFunction="loadList"
|
||||
:isLoading="isLoading"
|
||||
:setMetaItemsPerPage="setMetaItemsPerPage"
|
||||
<TableLabeledPagination
|
||||
:label="t('superuser.pages.complaints.title')"
|
||||
:hide-search="hideSearchField"
|
||||
>
|
||||
<template #paginationColumns>
|
||||
<template #description>
|
||||
<p class="is-size-6 mb-4">{{ t('superuser.pages.complaints.subtitle') }}</p>
|
||||
</template>
|
||||
|
||||
<template #paginationDisplayFiltersElement>
|
||||
<div class="column is-narrow my-3">
|
||||
<label class="label is-small">{{ t('superuser.pages.complaints.department_filter') }}</label>
|
||||
<div class="control">
|
||||
@@ -112,7 +110,7 @@ if (props.autoLoad) {
|
||||
<div class="select">
|
||||
<select
|
||||
data-testid="superuser-complaints-order-direction"
|
||||
@change="setOrder('created_at', $event.target.value); loadList();"
|
||||
@change="onOrderDirectionChange($event.target.value)"
|
||||
>
|
||||
<option value="desc" selected>{{ t('pagination.descending') }}</option>
|
||||
<option value="asc">{{ t('pagination.ascending') }}</option>
|
||||
@@ -121,26 +119,11 @@ if (props.autoLoad) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
|
||||
<CustomerComplaintsTable :objects="list" />
|
||||
|
||||
<PaginationNavigation
|
||||
:currentPage="metaCurrentPage"
|
||||
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
|
||||
:loadFunction="loadList"
|
||||
:setPage="setPage"
|
||||
:isLoading="isLoading"
|
||||
/>
|
||||
|
||||
<LoadButtonWhileAwait
|
||||
class="is-dark"
|
||||
:isLoading="isLoading"
|
||||
:loadFunction="loadList"
|
||||
icon="fas fa-sync-alt"
|
||||
>
|
||||
{{ t('pagination.reload') }}
|
||||
</LoadButtonWhileAwait>
|
||||
<template #default>
|
||||
<CustomerComplaintsTable :objects="list" />
|
||||
</template>
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+20
-46
@@ -1,45 +1,27 @@
|
||||
<script setup>
|
||||
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
usePaginatedList,
|
||||
PaginatedListKey
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
import { provide } from "vue";
|
||||
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
|
||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const props = defineProps({
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autoLoad: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const paginatedList = usePaginatedList();
|
||||
provide(PaginatedListKey, paginatedList);
|
||||
const {
|
||||
isLoaded,
|
||||
isLoading,
|
||||
list,
|
||||
loadList,
|
||||
metaCurrentPage,
|
||||
metaItemsPerPage,
|
||||
metaTotalItems,
|
||||
setEndpoint,
|
||||
setMetaItemsPerPage,
|
||||
setPage,
|
||||
metaSearch,
|
||||
search,
|
||||
setFilter,
|
||||
setOrder,
|
||||
hideSearchField,
|
||||
setHideSearchField,
|
||||
} = paginatedList;
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
||||
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { list, loadList, setEndpoint, hideSearchField, setHideSearchField } = paginatedList;
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
setEndpoint("/customers", false);
|
||||
|
||||
// Hide the search field, if the hideSearch prop is set
|
||||
if (props.hideSearch) {
|
||||
setHideSearchField(true);
|
||||
}
|
||||
@@ -47,20 +29,12 @@ if (props.hideSearch) {
|
||||
if (props.autoLoad) {
|
||||
loadList();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_customers')" v-if="!hideSearchField"/>
|
||||
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
||||
<template #paginationColumns>
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
<CustomersTable :objects="list" />
|
||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
||||
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
|
||||
<TableLabeledPagination :label="$t('customers.title')" :hide-search="hideSearchField">
|
||||
<CustomersTable :objects="list" />
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
+7
-33
@@ -3,9 +3,7 @@ import { computed, provide, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue";
|
||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||
@@ -14,15 +12,9 @@ const paginatedList = usePaginatedList();
|
||||
provide(PaginatedListKey, paginatedList);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
list,
|
||||
loadList,
|
||||
metaCurrentPage,
|
||||
metaItemsPerPage,
|
||||
metaTotalItems,
|
||||
setEndpoint,
|
||||
setMetaItemsPerPage,
|
||||
setPage,
|
||||
setFilter,
|
||||
setOrder,
|
||||
hideSearchField,
|
||||
@@ -54,14 +46,11 @@ if (props.autoLoad) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationDisplayGeneralSearchReload v-if="!hideSearchField" />
|
||||
<PaginationDisplay
|
||||
:metaItemsPerPage="metaItemsPerPage"
|
||||
:loadFunction="loadList"
|
||||
:isLoading="isLoading"
|
||||
:setMetaItemsPerPage="setMetaItemsPerPage"
|
||||
<TableLabeledPagination
|
||||
:label="t('common.departments')"
|
||||
:hide-search="hideSearchField"
|
||||
>
|
||||
<template #paginationColumns>
|
||||
<template #paginationDisplayFiltersElement>
|
||||
<div class="column is-narrow my-3 department-status-filter">
|
||||
<label class="label is-small" for="department-archive-filter">{{
|
||||
t("common.status")
|
||||
@@ -81,23 +70,8 @@ if (props.autoLoad) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
<div v-if="hideSearchField" class="mb-3">
|
||||
<button class="button is-dark" :class="{ 'is-loading': isLoading }" :disabled="isLoading" @click="loadList">
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-sync-alt" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ t("pagination.reload") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<DepartmentsTable :objects="sortedList" />
|
||||
<PaginationNavigation
|
||||
:currentPage="metaCurrentPage"
|
||||
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
|
||||
:loadFunction="loadList"
|
||||
:setPage="setPage"
|
||||
:isLoading="isLoading"
|
||||
/>
|
||||
<DepartmentsTable :objects="sortedList" />
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+20
-3
@@ -7,7 +7,24 @@ import PaginationDisplay from "@/components/displays/pagination/PaginationDispla
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const props = defineProps(["hideSearch", "autoLoad"]);
|
||||
const props = defineProps({
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autoLoad: {
|
||||
type: [Boolean, String],
|
||||
default: false,
|
||||
},
|
||||
endpoint: {
|
||||
type: String,
|
||||
default: "/subusers",
|
||||
},
|
||||
showCustomer: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
const paginatedList = usePaginatedList();
|
||||
@@ -30,7 +47,7 @@ const {
|
||||
setAdditionalQueryParameters,
|
||||
} = paginatedList;
|
||||
|
||||
setEndpoint("/subusers", false);
|
||||
setEndpoint(props.endpoint, false);
|
||||
setAdditionalQueryParameters({ include_non_enabled: true });
|
||||
setOrder("created_at", "desc");
|
||||
|
||||
@@ -82,7 +99,7 @@ if (props.autoLoad) {
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
|
||||
<SubusersTable :objects="list" />
|
||||
<SubusersTable :objects="list" :show-customer="showCustomer" />
|
||||
|
||||
<PaginationNavigation
|
||||
:currentPage="metaCurrentPage"
|
||||
|
||||
@@ -1,45 +1,21 @@
|
||||
<script setup>
|
||||
import UsersTable from "@/components/displays/superuser/tables/usersTable.vue";
|
||||
|
||||
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
usePaginatedList,
|
||||
PaginatedListKey
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
import { provide } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||
import UsersTable from "@/components/displays/superuser/tables/usersTable.vue";
|
||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||
|
||||
const paginatedList = usePaginatedList();
|
||||
provide(PaginatedListKey, paginatedList);
|
||||
const {
|
||||
isLoaded,
|
||||
isLoading,
|
||||
list,
|
||||
loadList,
|
||||
metaCurrentPage,
|
||||
metaItemsPerPage,
|
||||
metaTotalItems,
|
||||
setEndpoint,
|
||||
setMetaItemsPerPage,
|
||||
setPage,
|
||||
metaSearch,
|
||||
search,
|
||||
setFilter,
|
||||
setOrder,
|
||||
hideSearchField,
|
||||
setHideSearchField,
|
||||
} = paginatedList;
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
const { list, loadList, setEndpoint, setFilter, setOrder, hideSearchField, setHideSearchField } = paginatedList;
|
||||
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
setEndpoint("/users", false);
|
||||
setFilter("customer_number", 0, false);
|
||||
setOrder("created_at", "desc");
|
||||
|
||||
// Hide the search field, if the hideSearch prop is set
|
||||
if (props.hideSearch) {
|
||||
@@ -49,32 +25,35 @@ if (props.hideSearch) {
|
||||
if (props.autoLoad) {
|
||||
loadList();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_user')" v-if="!hideSearchField"/>
|
||||
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
||||
<template #paginationColumns>
|
||||
<TableLabeledPagination
|
||||
:label="t('superuser.pages.employees.title')"
|
||||
:hide-search="hideSearchField"
|
||||
:search-placeholder="t('global.search_user')"
|
||||
>
|
||||
<template #paginationDisplayFiltersElement>
|
||||
<!-- Sort by created_at -->
|
||||
<div class="column is-narrow my-3">
|
||||
<label class="label is-small">{{ t('pagination.order_direction') }}</label>
|
||||
<label class="label is-small">{{ t("pagination.order_direction") }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setOrder('created_at', $event.target.value); loadList();">
|
||||
<option value="asc">{{ t('pagination.ascending') }}</option>
|
||||
<option value="desc" selected>{{ t('pagination.descending') }}</option>
|
||||
<select
|
||||
@change="
|
||||
setOrder('created_at', $event.target.value);
|
||||
loadList();
|
||||
"
|
||||
>
|
||||
<option value="asc">{{ t("pagination.ascending") }}</option>
|
||||
<option value="desc" selected>{{ t("pagination.descending") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
<UsersTable :objects="list" />
|
||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
||||
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
|
||||
<UsersTable :objects="list" />
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
+226
-117
@@ -1,25 +1,21 @@
|
||||
<script setup>
|
||||
import {
|
||||
list,
|
||||
loadList,
|
||||
setEndpoint,
|
||||
setFilter,
|
||||
setOrder
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import { list, loadList, setEndpoint, setFilter, setOrder } from "@/components/pagination/paginatedList.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||
import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displays/tables/OrderBookingsTable.vue";
|
||||
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
|
||||
import { Colors } from "@/ThemeConfig.vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t } = useI18n();
|
||||
/**
|
||||
* Router
|
||||
*/
|
||||
const router = useRouter();
|
||||
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
|
||||
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
|
||||
/**
|
||||
* Props
|
||||
*/
|
||||
@@ -27,41 +23,40 @@ const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
required: false
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
const orderIdFilter = ref('*');
|
||||
});
|
||||
const orderIdFilter = ref("*");
|
||||
const onOrderIdFilterChange = (event) => {
|
||||
const val = event.target.value;
|
||||
orderIdFilter.value = val;
|
||||
// Apply filter: '*' clears, 'is null' shows without order, 'not null' shows with order
|
||||
setFilter('order_id', val);
|
||||
|
||||
setFilter("order_id", val);
|
||||
};
|
||||
|
||||
// Department filter
|
||||
const departmentFilter = ref('*');
|
||||
const departmentFilter = ref("*");
|
||||
const onDepartmentFilterChange = (event) => {
|
||||
const val = event.target.value;
|
||||
departmentFilter.value = val;
|
||||
setFilter('department', val);
|
||||
setFilter("department", val);
|
||||
};
|
||||
|
||||
// Only today filter
|
||||
const onlyTodayFilter = ref('*');
|
||||
const onlyTodayFilter = ref("*");
|
||||
const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
|
||||
const val = event.target.value;
|
||||
onlyTodayFilter.value = val;
|
||||
if (val === '*') {
|
||||
setFilter('datetime', val, false); // Clear filter
|
||||
setFilter('datetime-date_from', null, false);
|
||||
setFilter('datetime-date_to', null, false);
|
||||
if (val === "*") {
|
||||
setFilter("datetime", val, false); // Clear filter
|
||||
setFilter("datetime-date_from", null, false);
|
||||
setFilter("datetime-date_to", null, false);
|
||||
} else {
|
||||
const startOfDay = new Date().setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date().setHours(23, 59, 59, 999);
|
||||
//setFilter('datetime', null, false);
|
||||
setFilter('datetime-date_from', new Date(startOfDay).toISOString(), false);
|
||||
setFilter('datetime-date_to', new Date(endOfDay).toISOString(), false);
|
||||
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
|
||||
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
|
||||
}
|
||||
if (autoLoadList) {
|
||||
loadList();
|
||||
@@ -69,34 +64,38 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
|
||||
};
|
||||
|
||||
// Version selector + helpers
|
||||
const versionSelector = ref('new');
|
||||
const versionSelector = ref("new");
|
||||
const resetVersionToNew = () => {
|
||||
versionSelector.value = 'new';
|
||||
versionSelector.value = "new";
|
||||
};
|
||||
const showLegacyOrderBookingsPortal = () => {
|
||||
const departmentId = SessionUser.functions.getDepartmentIdFromUrl();
|
||||
if (!departmentId) {
|
||||
SessionUser.functions.redirectTo.user('/bookings-legacy', true);
|
||||
SessionUser.functions.redirectTo.user("/bookings-legacy", true);
|
||||
return;
|
||||
}
|
||||
SessionUser.functions.redirectTo.department(SessionUser.functions.getDepartmentIdFromUrl(), 'modules/bookings-legacy', true);
|
||||
SessionUser.functions.redirectTo.department(
|
||||
SessionUser.functions.getDepartmentIdFromUrl(),
|
||||
"modules/bookings-legacy",
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
// Filters
|
||||
onMounted(() => {
|
||||
setEndpoint(SessionUser.objects.order_bookings.meta.endpoint, false);
|
||||
setOrder('datetime', 'desc', false);
|
||||
setOrder("datetime", "desc", false);
|
||||
// Apply initial filters from props
|
||||
for (const [key, value] of Object.entries(props.filters)) {
|
||||
let setFilterKey = true;
|
||||
// Also set the filter controls if applicable
|
||||
if (key === 'order_id') {
|
||||
if (key === "order_id") {
|
||||
orderIdFilter.value = value;
|
||||
} else if (key === 'department') {
|
||||
} else if (key === "department") {
|
||||
departmentFilter.value = value;
|
||||
} else if (key === 'only_today' && value === true) {
|
||||
} else if (key === "only_today" && value === true) {
|
||||
setFilterKey = false; // Since the only_today filter is handled separately
|
||||
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split('T')[0] } }, false);
|
||||
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
|
||||
}
|
||||
if (setFilterKey) {
|
||||
setFilter(key, value, false);
|
||||
@@ -106,108 +105,218 @@ onMounted(() => {
|
||||
// Load departments for filter options
|
||||
getDepartments().catch(() => {});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<TableLabeledPagination :label="t('pagination.bookings_overview')">
|
||||
<template #paginationDisplayFiltersElement>
|
||||
<!-- Status filter -->
|
||||
<div class="column is-narrow">
|
||||
<div class="field mb-0">
|
||||
<label class="label is-small">{{ t('common.status') }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select :value="orderIdFilter" @change="onOrderIdFilterChange">
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option value="is null">{{ t('pagination.not_completed') }}</option>
|
||||
<option value="not null">{{ t('pagination.completed') }}</option>
|
||||
</select>
|
||||
<div class="order-bookings-pagination">
|
||||
<TableLabeledPagination :label="t('pagination.bookings_overview')" class="order-bookings-pagination__table">
|
||||
<template #paginationDisplayFiltersElement>
|
||||
<!-- Status filter -->
|
||||
<div class="column is-narrow order-bookings-pagination__filter">
|
||||
<div class="field mb-0">
|
||||
<label class="label is-small">{{ t("common.status") }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select :value="orderIdFilter" @change="onOrderIdFilterChange">
|
||||
<option value="*">{{ t("common.all") }}</option>
|
||||
<option value="is null">{{ t("pagination.not_completed") }}</option>
|
||||
<option value="not null">{{ t("pagination.completed") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Department filter -->
|
||||
<div class="column is-narrow">
|
||||
<div class="field mb-0">
|
||||
<label class="label is-small">{{ t('pagination.department') }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select :value="departmentFilter" @change="onDepartmentFilterChange">
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option v-for="department in departments" :key="department.id" :value="department.id">{{ department.name }}</option>
|
||||
</select>
|
||||
<!-- Department filter -->
|
||||
<div class="column is-narrow order-bookings-pagination__filter">
|
||||
<div class="field mb-0">
|
||||
<label class="label is-small">{{ t("pagination.department") }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select :value="departmentFilter" @change="onDepartmentFilterChange">
|
||||
<option value="*">{{ t("common.all") }}</option>
|
||||
<option v-for="department in departments" :key="department.id" :value="department.id">
|
||||
{{ department.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Version selector -->
|
||||
<div class="column is-narrow">
|
||||
<div class="field mb-0">
|
||||
<label class="label is-small">{{ t('pagination.version') }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="showLegacyOrderBookingsPortal(); resetVersionToNew()" v-model="versionSelector">
|
||||
<option value="new">{{ t('common.new') }}</option>
|
||||
<option value="legacy">{{ t('pagination.old') }}</option>
|
||||
</select>
|
||||
<!-- Version selector -->
|
||||
<div class="column is-narrow order-bookings-pagination__filter">
|
||||
<div class="field mb-0">
|
||||
<label class="label is-small">{{ t("pagination.version") }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select
|
||||
@change="
|
||||
showLegacyOrderBookingsPortal();
|
||||
resetVersionToNew();
|
||||
"
|
||||
v-model="versionSelector"
|
||||
>
|
||||
<option value="new">{{ t("common.new") }}</option>
|
||||
<option value="legacy">{{ t("pagination.old") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Only today filter -->
|
||||
<div class="column is-narrow">
|
||||
<div class="field mb-0">
|
||||
<label class="label is-small">{{ t('pagination.only_today') }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option :value="new Date().toISOString().split('T')[0]">{{ t('common.yes') }}</option>
|
||||
</select>
|
||||
<!-- Only today filter -->
|
||||
<div class="column is-narrow order-bookings-pagination__filter" v-if="!isUserRoute">
|
||||
<div class="field mb-0">
|
||||
<label class="label is-small">{{ t("pagination.only_today") }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
|
||||
<option value="*">{{ t("common.all") }}</option>
|
||||
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #leftPaginationColumns>
|
||||
</template>
|
||||
<template #rightPaginationColumns>
|
||||
<!-- Toggles and actions depending on route -->
|
||||
<!-- User: Only today switch + New booking button -->
|
||||
<div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/user')">
|
||||
<label class="label is-small">{{ t('pagination.show_only_today') }}</label>
|
||||
<div class="field">
|
||||
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }) }" :class="{ 'is-link': onlyTodayFilter !== '*' }" :checked="onlyTodayFilter !== '*'" />
|
||||
<label for="today"></label>
|
||||
</template>
|
||||
<template #leftPaginationColumns> </template>
|
||||
<template #rightPaginationColumns>
|
||||
<!-- Toggles and actions depending on route -->
|
||||
<!-- User: Only today switch + New booking button -->
|
||||
<div class="column is-narrow order-bookings-pagination__actions" v-if="isUserRoute">
|
||||
<div class="order-bookings-pagination__actions-grid">
|
||||
<div class="order-bookings-pagination__today-action">
|
||||
<label class="label is-small">{{ t("pagination.show_only_today") }}</label>
|
||||
<div class="field mb-0">
|
||||
<input
|
||||
id="today"
|
||||
type="checkbox"
|
||||
class="switch is-rounded"
|
||||
@change="
|
||||
(event) => {
|
||||
onOnlyTodayFilterChange({
|
||||
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
|
||||
});
|
||||
}
|
||||
"
|
||||
:class="{ 'is-link': onlyTodayFilter !== '*' }"
|
||||
:checked="onlyTodayFilter !== '*'"
|
||||
/>
|
||||
<label for="today"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-bookings-pagination__new-booking-action">
|
||||
<label class="label is-small order-bookings-pagination__desktop-spacer"> </label>
|
||||
<button
|
||||
class="button is-link button-same-width"
|
||||
data-testid="user-bookings-new-booking"
|
||||
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
|
||||
>
|
||||
{{ t("pagination.new_booking") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow is-float-right" v-if="router.currentRoute.value.path.startsWith('/user')">
|
||||
<label class="label is-small"> </label>
|
||||
<button
|
||||
class="button is-link button-same-width"
|
||||
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
|
||||
>
|
||||
{{ t('pagination.new_booking') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Admin: pending + only today combined switch -->
|
||||
<div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/admin')">
|
||||
<label class="label is-small">{{ t('pagination.show_only_todays_pending') }}</label>
|
||||
<div class="field">
|
||||
<input id="today-pending" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }, false); onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } }); }" :class="{ 'is-link': onlyTodayFilter !== '*' && orderIdFilter === 'is null' }" :checked="onlyTodayFilter !== '*' && orderIdFilter === 'is null'" />
|
||||
<label for="today-pending"></label>
|
||||
<!-- Admin: pending + only today combined switch -->
|
||||
<div class="column is-narrow" v-if="isAdminRoute">
|
||||
<label class="label is-small">{{ t("pagination.show_only_todays_pending") }}</label>
|
||||
<div class="field">
|
||||
<input
|
||||
id="today-pending"
|
||||
type="checkbox"
|
||||
class="switch is-rounded"
|
||||
@change="
|
||||
(event) => {
|
||||
onOnlyTodayFilterChange(
|
||||
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
|
||||
false
|
||||
);
|
||||
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
|
||||
}
|
||||
"
|
||||
:class="{ 'is-link': onlyTodayFilter !== '*' && orderIdFilter === 'is null' }"
|
||||
:checked="onlyTodayFilter !== '*' && orderIdFilter === 'is null'"
|
||||
/>
|
||||
<label for="today-pending"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<OrderBookingsTable :objects="list" />
|
||||
</template>
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
<template #default>
|
||||
<OrderBookingsTable :objects="list" />
|
||||
</template>
|
||||
</TableLabeledPagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-bookings-pagination__filter .select,
|
||||
.order-bookings-pagination__filter select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
</style>
|
||||
.order-bookings-pagination__actions-grid {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.order-bookings-pagination__today-action .field {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.order-bookings-pagination__new-booking-action .button {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.order-bookings-pagination__table :deep([data-testid="table-labeled-pagination-filters"] > .columns) {
|
||||
align-items: flex-start;
|
||||
column-gap: 0.75rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
row-gap: 0.85rem;
|
||||
}
|
||||
|
||||
.order-bookings-pagination__table :deep([data-testid="table-labeled-pagination-filters"] > .columns > .column),
|
||||
.order-bookings-pagination__filter,
|
||||
.order-bookings-pagination__actions {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.order-bookings-pagination__filter {
|
||||
flex: 1 1 calc(50% - 0.375rem);
|
||||
max-width: calc(50% - 0.375rem);
|
||||
}
|
||||
|
||||
.order-bookings-pagination__table
|
||||
:deep([data-testid="table-labeled-pagination-filters"] > .columns > .columns.is-multiline) {
|
||||
flex: 1 1 100%;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.order-bookings-pagination__actions {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.order-bookings-pagination__actions-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.order-bookings-pagination__new-booking-action .button {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.order-bookings-pagination__desktop-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { BSkeleton } from "buefy";
|
||||
|
||||
const props = defineProps<{
|
||||
src: string | null;
|
||||
alt?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "error"): void;
|
||||
(e: "load"): void;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
(src) => {
|
||||
isLoading.value = !!src;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const onLoad = () => {
|
||||
isLoading.value = false;
|
||||
emit("load");
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
isLoading.value = false;
|
||||
emit("error");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="src"
|
||||
class="self-serve-dynamic-image-frame mb-4"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
data-testid="self-serve-dynamic-image-frame"
|
||||
>
|
||||
<b-skeleton
|
||||
v-if="isLoading"
|
||||
class="self-serve-dynamic-image-skeleton"
|
||||
width="100%"
|
||||
height="100%"
|
||||
data-testid="self-serve-dynamic-image-skeleton"
|
||||
/>
|
||||
<img
|
||||
:key="src"
|
||||
:src="src"
|
||||
:alt="alt || 'Machine status'"
|
||||
data-testid="self-serve-dynamic-image"
|
||||
class="self-serve-dynamic-image"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@load="onLoad"
|
||||
@error="onError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-dynamic-image-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.self-serve-dynamic-image-frame {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image.is-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import { computed } from "vue";
|
||||
import { BButton, BField } from "buefy";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
steps: Array<any>;
|
||||
currentStep: number;
|
||||
showActions?: boolean;
|
||||
}>(), {
|
||||
showActions: true,
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
steps: Array<any>;
|
||||
currentStep: number;
|
||||
showActions?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showActions: true,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:currentStep", value: number): void;
|
||||
@@ -45,9 +48,6 @@ const goNext = () => {
|
||||
<div class="guided-instructions" data-testid="self-serve-guided-instructions">
|
||||
<div class="guided-instructions__header">
|
||||
<h3 class="title is-6">{{ $t("self_wash.follow_steps") }}</h3>
|
||||
<span class="guided-instructions__counter">
|
||||
{{ normalizedStepIndex + 1 }} / {{ steps.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section
|
||||
@@ -55,8 +55,18 @@ const goNext = () => {
|
||||
class="guided-instructions__step"
|
||||
:data-testid="`self-serve-guided-step-${normalizedStepIndex}`"
|
||||
>
|
||||
<b-field :label="`${normalizedStepIndex + 1}. ${activeStep.title}`">
|
||||
<div class="guided-instructions__content">
|
||||
<b-field>
|
||||
<template #label>
|
||||
<span class="guided-instructions__step-label" data-testid="self-serve-guided-step-label">
|
||||
<span class="guided-instructions__step-title" data-testid="self-serve-guided-step-title">
|
||||
{{ normalizedStepIndex + 1 }}. {{ activeStep.title }}
|
||||
</span>
|
||||
<span class="guided-instructions__counter" data-testid="self-serve-guided-counter">
|
||||
{{ normalizedStepIndex + 1 }}/{{ steps.length }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="guided-instructions__content" data-testid="self-serve-guided-content">
|
||||
<p v-if="activeStep.content" class="guided-instructions__paragraph">{{ activeStep.content }}</p>
|
||||
<template v-for="(brush, brushIndex) in activeStep.brushes || []" :key="brushIndex">
|
||||
<p class="guided-instructions__paragraph">
|
||||
@@ -105,14 +115,28 @@ const goNext = () => {
|
||||
.guided-instructions__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.guided-instructions__step-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.guided-instructions__step-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.guided-instructions__counter {
|
||||
flex: 0 0 auto;
|
||||
color: #566074;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { BField, BIcon, BMessage, BRadioButton } from "buefy";
|
||||
import { computed } from "vue";
|
||||
import { BIcon, BMessage, BRadioButton } from "buefy";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
lanes: Array<any>;
|
||||
selectedLaneId: number | string | null;
|
||||
washType: string;
|
||||
@@ -14,19 +15,30 @@ const emit = defineEmits<{
|
||||
(e: "update:selectedLaneId", value: number): void;
|
||||
(e: "update:washType", value: string): void;
|
||||
}>();
|
||||
|
||||
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="self-serve-lane-step">
|
||||
<h1 class="title has-text-centered">{{ $t("self_wash.start_wash") }}</h1>
|
||||
<b-field :label="$t('self_wash.wash_lane')">
|
||||
<div class="columns is-multiline is-mobile" :class="{ 'is-centered': lanes.length >= 2 }">
|
||||
<section class="self-serve-choice-group" data-testid="self-serve-lane-choice-group">
|
||||
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_lane") }}</h2>
|
||||
<div
|
||||
class="self-serve-choice-grid"
|
||||
:class="{ 'self-serve-choice-grid--centered': lanes.length >= 2 }"
|
||||
data-testid="self-serve-lane-options"
|
||||
>
|
||||
<template v-for="lane in lanes" :key="lane.id">
|
||||
<div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
|
||||
<div class="self-serve-choice-grid__item">
|
||||
<b-radio-button
|
||||
class="self-serve-choice-card"
|
||||
:model-value="selectedLaneId"
|
||||
:native-value="lane.id"
|
||||
type="is-link"
|
||||
:disabled="!isLaneAvailable(lane)"
|
||||
:title="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
|
||||
:aria-label="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
|
||||
:data-testid="`self-serve-lane-option-${lane.id}`"
|
||||
@update:model-value="emit('update:selectedLaneId', lane.id)"
|
||||
@input="emit('update:selectedLaneId', lane.id)"
|
||||
@@ -36,7 +48,7 @@ const emit = defineEmits<{
|
||||
<span v-if="!isLaneAvailable(lane)">
|
||||
<small>
|
||||
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
||||
{{ $t("self_wash.occupied") }}
|
||||
{{ $t("self_wash.lane_unavailable") }}
|
||||
</small>
|
||||
</span>
|
||||
<span v-else>
|
||||
@@ -49,18 +61,18 @@ const emit = defineEmits<{
|
||||
</b-radio-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="lanes.length === 0" class="column is-12">
|
||||
<b-message type="is-warning" aria-close-label="Luk besked">
|
||||
Ingen vaskebaner tilgaengelige for selvvask i <b>{{ departmentName || "denne" }}</b> afdeling.
|
||||
</b-message>
|
||||
</div>
|
||||
</div>
|
||||
</b-field>
|
||||
<b-message v-if="lanes.length === 0" type="is-warning" aria-close-label="Luk besked">
|
||||
Ingen vaskebaner tilgængelige for selvvask i <b>{{ departmentName || "denne" }}</b> afdeling.
|
||||
</b-message>
|
||||
</section>
|
||||
|
||||
<b-field label="Maskine eller manuel vask">
|
||||
<div class="columns is-mobile is-centered is-multiline">
|
||||
<div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
|
||||
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
|
||||
<h2 class="self-serve-choice-label">Maskine eller manuel vask</h2>
|
||||
<div class="self-serve-choice-grid self-serve-choice-grid--centered" data-testid="self-serve-wash-type-options">
|
||||
<div class="self-serve-choice-grid__item">
|
||||
<b-radio-button
|
||||
class="self-serve-choice-card"
|
||||
:model-value="washType"
|
||||
native-value="Manual"
|
||||
type="is-link"
|
||||
@@ -72,24 +84,27 @@ const emit = defineEmits<{
|
||||
<span>Manuel<br /></span>
|
||||
<small>
|
||||
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
|
||||
Tilgaengelig
|
||||
Tilgængelig
|
||||
</small>
|
||||
</span>
|
||||
</b-radio-button>
|
||||
</div>
|
||||
<div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
|
||||
<div class="self-serve-choice-grid__item">
|
||||
<b-radio-button
|
||||
class="self-serve-choice-card"
|
||||
:model-value="washType"
|
||||
native-value="Machine"
|
||||
type="is-link"
|
||||
:disabled="!isMachineAvailable(selectedLaneId)"
|
||||
:disabled="!isSelectedMachineAvailable"
|
||||
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
|
||||
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
|
||||
data-testid="self-serve-wash-type-machine"
|
||||
@update:model-value="emit('update:washType', 'Machine')"
|
||||
@input="emit('update:washType', 'Machine')"
|
||||
>
|
||||
<span>
|
||||
<span>{{ $t("self_wash.machine") }}<br /></span>
|
||||
<span v-if="!isMachineAvailable(selectedLaneId)">
|
||||
<span v-if="!isSelectedMachineAvailable">
|
||||
<small>
|
||||
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
||||
{{ $t("self_wash.unavailable") }}
|
||||
@@ -105,6 +120,77 @@ const emit = defineEmits<{
|
||||
</b-radio-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-field>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-choice-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.self-serve-choice-label {
|
||||
color: #303440;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.self-serve-choice-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-bottom: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-choice-grid--centered {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.self-serve-choice-grid__item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.self-serve-choice-card {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
min-height: 5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-choice-card :deep(.button) {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
min-height: 5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-choice-card :deep(.button),
|
||||
.self-serve-choice-card span,
|
||||
.self-serve-choice-card :deep(.button span) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.self-serve-choice-card small,
|
||||
.self-serve-choice-card :deep(.button small) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.self-serve-choice-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,7 +38,7 @@ const emit = defineEmits<{
|
||||
<div class="card-footer-item">
|
||||
<button
|
||||
class="button is-fullwidth"
|
||||
:class="[answers[question.id] === false ? 'is-danger is-light' : 'is-light']"
|
||||
:class="[answers[question.id] === false ? 'is-danger' : 'is-light']"
|
||||
:data-testid="`self-serve-question-${question.id}-no`"
|
||||
@click="emit('answer-question', question.id, false)"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { BButton, BIcon, BTooltip } from "buefy";
|
||||
import { BButton, BIcon } from "buefy";
|
||||
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
|
||||
|
||||
defineProps<{
|
||||
@@ -7,18 +7,9 @@ defineProps<{
|
||||
visibleQuestions: Array<any>;
|
||||
answers: Record<number, boolean | undefined>;
|
||||
editAnswers: boolean;
|
||||
showDebug: boolean;
|
||||
conditions: Array<any>;
|
||||
rules: Array<any>;
|
||||
evaluateCondition: (conditionId: number) => boolean;
|
||||
evaluateRule: (rule: any, visited?: Set<number>) => boolean;
|
||||
isQuestionVisible: (question: any) => boolean;
|
||||
getConditionById: (conditionId: number) => any;
|
||||
getRuleTypeLabel: (type: string) => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle-debug"): void;
|
||||
(e: "toggle-edit"): void;
|
||||
(e: "answer-question", questionId: number, value: boolean): void;
|
||||
}>();
|
||||
@@ -35,27 +26,8 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
|
||||
<p>{{ $t("self_wash.loading_data") }}</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="notification is-info is-light py-2 px-3 mb-4"
|
||||
data-testid="self-serve-questions-inline-loading"
|
||||
>
|
||||
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="is-flex is-justify-content-center is-align-items-center mb-4">
|
||||
<h1 class="title mb-0">{{ $t("self_wash.answer_questions") }}</h1>
|
||||
<b-button
|
||||
size="is-small"
|
||||
icon-left="bug"
|
||||
type="is-ghost"
|
||||
class="ml-2"
|
||||
data-testid="self-serve-toggle-debug"
|
||||
@click="emit('toggle-debug')"
|
||||
>
|
||||
Debug
|
||||
</b-button>
|
||||
<b-button
|
||||
v-if="editAnswers"
|
||||
size="is-small"
|
||||
@@ -69,75 +41,16 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<div v-if="showDebug" class="box mb-4 has-background-light" data-testid="self-serve-debug-panel">
|
||||
<h5 class="subtitle is-5">Debug: Betingelser</h5>
|
||||
<div class="tags">
|
||||
<b-tooltip
|
||||
v-for="condition in conditions"
|
||||
:key="condition.id"
|
||||
position="is-top"
|
||||
multilined
|
||||
type="is-dark"
|
||||
>
|
||||
<template #content>
|
||||
<div class="has-text-left">
|
||||
<p v-if="condition.description" class="mb-2"><i>{{ condition.description }}</i></p>
|
||||
<p class="is-size-7 has-text-weight-bold mb-1">Regler:</p>
|
||||
<div
|
||||
v-for="rule in rules.filter(entry => parseInt(entry.condition_id as any) === parseInt(condition.id))"
|
||||
:key="rule.id"
|
||||
class="is-size-7"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i :class="evaluateRule(rule, new Set([parseInt(condition.id)])) ? 'fas fa-check has-text-success' : 'fas fa-times has-text-danger'" />
|
||||
</span>
|
||||
<span class="ml-1">[{{ getRuleTypeLabel(rule.type) }}] {{ rule.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span class="tag" :class="evaluateCondition(condition.id) ? 'is-success' : 'is-light'">
|
||||
<span class="icon is-small mr-1">
|
||||
<i :class="evaluateCondition(condition.id) ? 'fas fa-check-circle' : 'fas fa-times-circle'" />
|
||||
</span>
|
||||
{{ condition.name }}
|
||||
</span>
|
||||
</b-tooltip>
|
||||
<div class="self-serve-questions-status-slot" aria-live="polite">
|
||||
<div
|
||||
class="notification is-info is-light py-2 px-3 mb-0"
|
||||
:class="{ 'is-invisible': !isLoading }"
|
||||
data-testid="self-serve-questions-inline-loading"
|
||||
:aria-hidden="!isLoading"
|
||||
>
|
||||
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h5 class="subtitle is-5">Debug: Alle mulige sporgsmal (synlighed)</h5>
|
||||
<ul>
|
||||
<li v-for="question in visibleQuestions" :key="question.id" class="is-size-7">
|
||||
<span class="icon is-small">
|
||||
<i :class="isQuestionVisible(question) ? 'fas fa-eye has-text-success' : 'fas fa-eye-slash has-text-grey-light'" />
|
||||
</span>
|
||||
{{ question.question }}
|
||||
<b-tooltip v-if="question.condition_id" position="is-top" multilined type="is-dark">
|
||||
<template #content>
|
||||
<div v-if="getConditionById(question.condition_id)" class="has-text-left">
|
||||
<p v-if="getConditionById(question.condition_id).description" class="mb-2">
|
||||
<i>{{ getConditionById(question.condition_id).description }}</i>
|
||||
</p>
|
||||
<p class="is-size-7 has-text-weight-bold mb-1">Regler:</p>
|
||||
<div
|
||||
v-for="rule in rules.filter(entry => parseInt(entry.condition_id as any) === parseInt(question.condition_id))"
|
||||
:key="rule.id"
|
||||
class="is-size-7"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i :class="evaluateRule(rule, new Set([parseInt(question.condition_id)])) ? 'fas fa-check has-text-success' : 'fas fa-times has-text-danger'" />
|
||||
</span>
|
||||
<span class="ml-1">[{{ getRuleTypeLabel(rule.type) }}] {{ rule.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span class="has-text-grey is-clickable">
|
||||
(Hvis: {{ getConditionById(question.condition_id)?.name || question.condition_id }})
|
||||
</span>
|
||||
</b-tooltip>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<SelfServeQuestionCards
|
||||
@@ -148,3 +61,20 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-questions-status-slot {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.self-serve-questions-status-slot .notification {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-questions-status-slot .notification.is-invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { BCheckbox, BField, BIcon } from "buefy";
|
||||
import { getSelfServeTaskDynamicImagePresentation } from "@/services/selfServeDynamicImage.js";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tasks: Array<any>;
|
||||
@@ -48,29 +49,50 @@ const getNonImageAttachments = (task: any) => {
|
||||
return task.attachments.filter((attachment: any) => !isImageAttachment(attachment));
|
||||
};
|
||||
|
||||
const normalizeTaskService = (service: any) => String(service || "").trim().toUpperCase();
|
||||
|
||||
const getTaskServiceTokens = (task: any) => {
|
||||
if (!Array.isArray(task?.services)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return task.services
|
||||
.map((service: any) => normalizeTaskService(service))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const taskUsesProgramPicker = (task: any) => getTaskServiceTokens(task).includes("PROGRAM_PICKER");
|
||||
|
||||
const getVisibleServices = (task: any) => {
|
||||
if (!Array.isArray(task?.services)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return task.services.filter((service: any) => String(service || "").trim().toUpperCase() !== "MACHINE");
|
||||
return task.services.filter((service: any) => normalizeTaskService(service) !== "MACHINE");
|
||||
};
|
||||
|
||||
const getProgramWheelSelection = (task: any) => {
|
||||
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
|
||||
if (rawValue === null || rawValue === undefined || rawValue === "") {
|
||||
if (!taskUsesProgramPicker(task)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(String(rawValue), 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
return getSelfServeTaskDynamicImagePresentation(task).thumbPosition;
|
||||
};
|
||||
|
||||
const formatTaskTitle = (task: any) => {
|
||||
const title = String(task?.task || "");
|
||||
const programWheelSelection = getProgramWheelSelection(task);
|
||||
|
||||
return programWheelSelection === null ? title : `#${programWheelSelection} ${title}`;
|
||||
if (programWheelSelection === null) {
|
||||
return title;
|
||||
}
|
||||
|
||||
const programTitle = title.match(/^(.*?\bprogram)(\b.*)$/i);
|
||||
if (programTitle) {
|
||||
return `${programTitle[1]} #${programWheelSelection}${programTitle[2]}`;
|
||||
}
|
||||
|
||||
return `${title} #${programWheelSelection}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { BIcon } from "buefy";
|
||||
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
|
||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
isLoading: boolean;
|
||||
dynamicImageUrl: string | null;
|
||||
activeTasks: Array<any>;
|
||||
@@ -24,6 +25,10 @@ const emitToggleTask = (taskId: number, value: boolean) => {
|
||||
const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined, attachment?: any) => {
|
||||
emit("download-attachment", taskId, attachmentId, attachment);
|
||||
};
|
||||
|
||||
const onDynamicImageError = () => {
|
||||
emit("clear-dynamic-image");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -42,15 +47,11 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="dynamicImageUrl" class="mb-4">
|
||||
<img
|
||||
:src="dynamicImageUrl"
|
||||
alt="Machine status"
|
||||
data-testid="self-serve-dynamic-image"
|
||||
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
|
||||
@error="emit('clear-dynamic-image')"
|
||||
/>
|
||||
</div>
|
||||
<SelfServeDynamicImageFrame
|
||||
:src="dynamicImageUrl"
|
||||
alt="Machine status"
|
||||
@error="onDynamicImageError"
|
||||
/>
|
||||
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
|
||||
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
|
||||
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
|
||||
|
||||
@@ -16,6 +16,7 @@ const props = defineProps<{
|
||||
availableProductIds: number[];
|
||||
vehicleTypes: Array<any>;
|
||||
vehicleStepError?: string | null;
|
||||
vehicleStepGuidance?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -109,6 +110,15 @@ const emitRegistration = (value: unknown) => {
|
||||
>
|
||||
{{ props.vehicleStepError }}
|
||||
</b-message>
|
||||
<b-message
|
||||
v-else-if="props.vehicleStepGuidance"
|
||||
type="is-info"
|
||||
has-icon
|
||||
:closable="false"
|
||||
data-testid="self-serve-vehicle-step-guidance"
|
||||
>
|
||||
{{ props.vehicleStepGuidance }}
|
||||
</b-message>
|
||||
<b-field :label="$t('self_wash.select_your_vehicle')">
|
||||
<template v-if="availableProductIds.length > 0">
|
||||
<SelfServeVehicleTypeSelector
|
||||
|
||||
@@ -29,7 +29,7 @@ const getLoadingVehicleTypes = (count: number): VehicleTypeTemplate[] => {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
loadingTypes.push({
|
||||
id: index,
|
||||
name: "Indlaeser...",
|
||||
name: "Indlæser...",
|
||||
price: 0,
|
||||
loading: true,
|
||||
});
|
||||
@@ -146,7 +146,7 @@ watch(() => props.selectedVehicleTypeId, (newId) => {
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
Vaelg venligst din koretojstype ved at klikke pa ikonet ovenfor.
|
||||
Vælg en køretøjstype ved at trykke på et af ikonerne ovenfor.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,21 +2,31 @@
|
||||
import Swal from "sweetalert2";
|
||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
|
||||
const props = defineProps({
|
||||
objects: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showCustomer: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { loadList } = usePaginatedListInstance();
|
||||
|
||||
const canEditPermissions = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
|
||||
const canDisableAccess = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_DELETE");
|
||||
const canEditPermissions = () =>
|
||||
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
|
||||
const canDisableAccess = () =>
|
||||
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_DELETE");
|
||||
const canResendInvite = (subuser) =>
|
||||
(SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
|
||||
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
|
||||
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
|
||||
const hasRowActions = (subuser) =>
|
||||
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) {
|
||||
@@ -50,6 +60,16 @@ const formatEmail = (subuser) => {
|
||||
return subuser?.setup_required ? "E-mail oplyses ved accept" : "-";
|
||||
};
|
||||
|
||||
const formatCustomer = (subuser) => {
|
||||
if (!props.showCustomer) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const number = subuser?.customer_number ?? "-";
|
||||
const name = subuser?.customer_name || "Ukendt kunde";
|
||||
return `${number} - ${name}`;
|
||||
};
|
||||
|
||||
const permissionSummary = (subuser) =>
|
||||
SessionUser.objects.subusers.functions.permissionSummary(subuser?.grant_permissions || []);
|
||||
|
||||
@@ -161,7 +181,7 @@ const onToggleEnabled = async (subuser, enabled) => {
|
||||
};
|
||||
|
||||
const onResendInvite = async (subuser) => {
|
||||
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList);
|
||||
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList, { superuser: props.showCustomer });
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -171,6 +191,7 @@ const onResendInvite = async (subuser) => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th v-if="showCustomer">Kunde</th>
|
||||
<th>Chauffør</th>
|
||||
<th>Kontakt</th>
|
||||
<th>Adgang</th>
|
||||
@@ -183,12 +204,17 @@ const onResendInvite = async (subuser) => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="props.objects.length === 0">
|
||||
<td colspan="9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
|
||||
<td :colspan="showCustomer ? 10 : 9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
|
||||
</tr>
|
||||
|
||||
<tr v-for="subuser in props.objects" :key="subuser.id">
|
||||
<tr v-for="subuser in props.objects" :key="`${subuser.id}-${subuser.grant_id || 'none'}`">
|
||||
<td>{{ subuser.id }}</td>
|
||||
|
||||
<td v-if="showCustomer">
|
||||
<div class="has-text-weight-semibold">{{ formatCustomer(subuser) }}</div>
|
||||
<div class="is-size-7 has-text-grey">Grant #{{ subuser.grant_id }}</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
|
||||
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
|
||||
@@ -219,14 +245,6 @@ const onResendInvite = async (subuser) => {
|
||||
|
||||
<td>
|
||||
<div>{{ subuser.grant_note || "-" }}</div>
|
||||
<button
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
class="button is-text is-small px-0 mt-1"
|
||||
type="button"
|
||||
@click="onEditNote(subuser)"
|
||||
>
|
||||
Redigér note
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<td>{{ formatDateTime(subuser.created_at) }}</td>
|
||||
@@ -234,36 +252,42 @@ const onResendInvite = async (subuser) => {
|
||||
|
||||
<td>
|
||||
<div class="buttons is-justify-content-flex-end action-buttons">
|
||||
<button
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
class="button is-small"
|
||||
type="button"
|
||||
:data-testid="`subuser-permissions-${subuser.id}`"
|
||||
@click="onEditPermissions(subuser)"
|
||||
>
|
||||
Tilladelser
|
||||
</button>
|
||||
<ActionSettingsWheelButton v-if="hasRowActions(subuser)" display-actions-directly>
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
icon="fas fa-pen"
|
||||
label="Redigér note"
|
||||
:click-action="() => onEditNote(subuser)"
|
||||
:test-id="`subuser-note-${subuser.id}`"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="canResendInvite(subuser)"
|
||||
class="button is-small"
|
||||
type="button"
|
||||
:data-testid="`subuser-resend-${subuser.id}`"
|
||||
@click="onResendInvite(subuser)"
|
||||
>
|
||||
Gensend
|
||||
</button>
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
icon="fas fa-user-shield"
|
||||
label="Tilladelser"
|
||||
:click-action="() => onEditPermissions(subuser)"
|
||||
:test-id="`subuser-permissions-${subuser.id}`"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="canDisableAccess() && subuser.grant_id"
|
||||
class="button is-small"
|
||||
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
|
||||
type="button"
|
||||
:data-testid="`subuser-toggle-${subuser.id}`"
|
||||
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
|
||||
>
|
||||
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
|
||||
</button>
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canResendInvite(subuser)"
|
||||
icon="fas fa-paper-plane"
|
||||
label="Gensend"
|
||||
:click-action="() => onResendInvite(subuser)"
|
||||
:test-id="`subuser-resend-${subuser.id}`"
|
||||
/>
|
||||
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canDisableAccess() && subuser.grant_id"
|
||||
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
|
||||
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
|
||||
:template="subuser.grant_enabled ? 'danger' : 'success'"
|
||||
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
|
||||
:test-id="`subuser-toggle-${subuser.id}`"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { getDepartmentDailyReportComplaintCategoryLabel } from "@/services/departmentDailyReportComplaintCategories.js";
|
||||
@@ -92,29 +94,24 @@ const formatCategory = (value) => (
|
||||
<td class="complaint-description">{{ complaint.description }}</td>
|
||||
<td>{{ formatCreatedBy(complaint) }}</td>
|
||||
<td class="has-text-right">
|
||||
<div class="buttons is-right is-justify-content-flex-end">
|
||||
<button
|
||||
class="button is-small is-dark"
|
||||
type="button"
|
||||
:data-testid="`superuser-complaint-edit-${complaint.id}`"
|
||||
@click="SessionUser.objects.department_daily_report_complaints.functions.showEditForm(complaint, loadList)"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-pen"></i>
|
||||
</span>
|
||||
<span>{{ $t('global.edit') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-small is-danger"
|
||||
type="button"
|
||||
:data-testid="`superuser-complaint-delete-${complaint.id}`"
|
||||
@click="SessionUser.objects.department_daily_report_complaints.functions.showDeleteConfirmationModal(complaint.id, loadList)"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-trash"></i>
|
||||
</span>
|
||||
<span>{{ $t('global.delete') }}</span>
|
||||
</button>
|
||||
<div class="buttons is-right is-justify-content-flex-end" :data-testid="`superuser-complaint-actions-${complaint.id}`">
|
||||
<ActionSettingsWheelButton>
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
icon="fas fa-pen"
|
||||
:label="$t('global.edit')"
|
||||
:test-id="`superuser-complaint-edit-${complaint.id}`"
|
||||
:click-action="() => SessionUser.objects.department_daily_report_complaints.functions.showEditForm(complaint, loadList)"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
icon="fas fa-trash"
|
||||
:label="$t('global.delete')"
|
||||
template="danger"
|
||||
:test-id="`superuser-complaint-delete-${complaint.id}`"
|
||||
:click-action="() => SessionUser.objects.department_daily_report_complaints.functions.showDeleteConfirmationModal(complaint.id, loadList)"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -102,26 +102,16 @@ const parseBalance = (user) => {
|
||||
{{ parseBalance(user) }}</td>
|
||||
<td>
|
||||
<div class="buttons is-float-right">
|
||||
<!-- Disabled customer actions -->
|
||||
<div class="dropdown is-right is-hoverable">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small is-danger is-inverted" aria-haspopup="true" aria-controls="dropdown-menu" @click="showCustomerBarred">
|
||||
<span class="icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a class="dropdown-item has-text-warning" @click="showCustomerBarred">
|
||||
<span class="icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</span>
|
||||
<span class="ml-1">Kundekonto er lukket i E-conomic</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ActionSettingsWheelButton icon="fas fa-exclamation-triangle">
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
label="Kundekonto er lukket i E-conomic"
|
||||
icon="fas fa-exclamation-triangle"
|
||||
template="warning"
|
||||
:click-action="showCustomerBarred"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -143,4 +133,4 @@ const parseBalance = (user) => {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup>
|
||||
const props = defineProps(["objects"]);
|
||||
import { ref } from "vue";
|
||||
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
|
||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||
const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = usePaginatedListInstance();
|
||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
// Get the departments (If the departments are not already loaded)
|
||||
if (departments.value.length === 0) {
|
||||
getDepartments();
|
||||
}
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
|
||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const props = defineProps(["objects"]);
|
||||
const { t } = useI18n();
|
||||
const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = usePaginatedListInstance();
|
||||
|
||||
const draggingIndex = ref(null);
|
||||
const dragOverIndex = ref(null);
|
||||
@@ -68,18 +70,6 @@ const onDrop = async (event, newIndex) => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseCustomerName = (user) => {
|
||||
if (user.customer_name) {
|
||||
return user.customer_name;
|
||||
} else {
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
|
||||
|
||||
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const redirect = (path) => {
|
||||
window.location = path;
|
||||
};
|
||||
@@ -136,7 +126,7 @@ const toggleArchived = async (department) => {
|
||||
<th>{{ SessionUser.objects.departments.columns.archived.label }}</th>
|
||||
<th>{{ SessionUser.objects.departments.columns.latitude.label }}</th>
|
||||
<th>{{ SessionUser.objects.departments.columns.longitude.label }}</th>
|
||||
<th>{{ $t("tables.actions") }}</th>
|
||||
<th class="has-text-right">{{ $t("tables.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -212,34 +202,41 @@ const toggleArchived = async (department) => {
|
||||
column="longitude"
|
||||
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
|
||||
/>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="button is-small"
|
||||
@click="
|
||||
showEditDepartmentForm(
|
||||
department.id,
|
||||
department.name,
|
||||
department.description,
|
||||
department.economic_department_id
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit"></i>
|
||||
</span>
|
||||
</button>
|
||||
<button class="button is-small" @click="redirect('/admin/' + department.id)">
|
||||
<!-- External link icon -->
|
||||
<span class="icon">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</span>
|
||||
</button>
|
||||
<button class="button is-small is-dark" @click="redirect('/superuser/departments/' + department.id)">
|
||||
<span class="icon">
|
||||
<i class="fas fa-cog"></i>
|
||||
</span>
|
||||
</button>
|
||||
<td class="has-text-right">
|
||||
<div
|
||||
class="buttons is-right is-justify-content-flex-end"
|
||||
:data-testid="`superuser-department-actions-${department.id}`"
|
||||
>
|
||||
<ActionSettingsWheelButton>
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
icon="fas fa-edit"
|
||||
:label="t('global.edit')"
|
||||
:test-id="`superuser-department-edit-${department.id}`"
|
||||
:click-action="
|
||||
() =>
|
||||
showEditDepartmentForm(
|
||||
department.id,
|
||||
department.name,
|
||||
department.description,
|
||||
department.economic_department_id
|
||||
)
|
||||
"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
icon="fas fa-external-link-alt"
|
||||
:label="t('global.open')"
|
||||
:test-id="`superuser-department-open-${department.id}`"
|
||||
:click-action="() => redirect('/admin/' + department.id)"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
icon="fas fa-cog"
|
||||
:label="t('global.settings')"
|
||||
:test-id="`superuser-department-settings-${department.id}`"
|
||||
:click-action="() => redirect('/superuser/departments/' + department.id)"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,44 +1,32 @@
|
||||
<script setup>
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps(['objects']);
|
||||
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// Get the departments (If the departments are not already loaded)
|
||||
if (departments.value.length === 0) {
|
||||
getDepartments();
|
||||
}
|
||||
|
||||
|
||||
|
||||
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
|
||||
|
||||
const redirect = (path) => {
|
||||
window.location = path;
|
||||
}
|
||||
|
||||
|
||||
defineProps({
|
||||
objects: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const editUser = (user) => {
|
||||
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table class="table is-fullwidth">
|
||||
<thead>
|
||||
<table class="table is-fullwidth" data-testid="superuser-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('objects.columns.id') }}</th>
|
||||
<th>{{ $t('tables.users.name') }}</th>
|
||||
<th>{{ $t('tables.users.role') }}</th>
|
||||
<th class="has-text-right">{{ $t('tables.actions') }}</th>
|
||||
<th>{{ $t("objects.columns.id") }}</th>
|
||||
<th>{{ $t("tables.users.name") }}</th>
|
||||
<th>{{ $t("tables.users.role") }}</th>
|
||||
<th class="has-text-right">{{ $t("tables.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in objects" :key="user.id">
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in objects" :key="user.id" :data-testid="`superuser-users-row-${user.id}`">
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.display_name }}</td>
|
||||
<td>{{ user.group_id }}</td>
|
||||
@@ -46,25 +34,27 @@ const redirect = (path) => {
|
||||
<div class="buttons is-float-right">
|
||||
<!-- Settings wheel -->
|
||||
<ActionSettingsWheelButton
|
||||
:customer_number="user.customer_number"
|
||||
:user_id="user.id"
|
||||
:customer_number="user.customer_number"
|
||||
:user_id="user.id"
|
||||
:data-testid="`superuser-user-actions-${user.id}`"
|
||||
>
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
@click="showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id)"
|
||||
icon="fas fa-user-edit"
|
||||
:label="$t('global.edit')"
|
||||
:click-action="() => editUser(user)"
|
||||
icon="fas fa-user-edit"
|
||||
:label="$t('global.edit')"
|
||||
:test-id="`superuser-user-edit-${user.id}`"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<tr v-if="objects.length === 0">
|
||||
<td colspan="4">{{ $t("global.no_data") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
import { departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
|
||||
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { loadList } from "@/components/pagination/paginatedList.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
@@ -27,16 +27,15 @@ const props = defineProps({
|
||||
reloadList: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: null
|
||||
default: null,
|
||||
},
|
||||
add_other_customer_id: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const reload = () => {
|
||||
// Load the list of vehicles
|
||||
if (props.reloadList) {
|
||||
@@ -59,13 +58,9 @@ if (departments.value.length === 0) {
|
||||
const onClickListAddons = (vehicleId) => {
|
||||
// Redirect to the product addons page
|
||||
console.log("Fetching product addons for vehicle: " + vehicleId);
|
||||
SessionUser.request(
|
||||
'/vehicles/addons/available',
|
||||
'GET',
|
||||
{
|
||||
id: vehicleId
|
||||
},
|
||||
)
|
||||
SessionUser.request("/vehicles/addons/available", "GET", {
|
||||
id: vehicleId,
|
||||
});
|
||||
};
|
||||
|
||||
const vehicleAddons = ref(null);
|
||||
@@ -76,13 +71,9 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
|
||||
// Get the vehicle addons from the server
|
||||
if (vehicleAddons.value === null) {
|
||||
vehicleAddons.value = [];
|
||||
SessionUser.request(
|
||||
'/vehicles/addons/available',
|
||||
'GET',
|
||||
{
|
||||
id: vehicleId
|
||||
},
|
||||
).then((response) => {
|
||||
SessionUser.request("/vehicles/addons/available", "GET", {
|
||||
id: vehicleId,
|
||||
}).then((response) => {
|
||||
console.log("Vehicle addons: ", response.data.data);
|
||||
vehicleAddons.value = response.data.data;
|
||||
});
|
||||
@@ -93,14 +84,10 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
|
||||
|
||||
const toggleVehicleAddon = (vehicleId, addonId) => {
|
||||
// Toggle the vehicle addon
|
||||
SessionUser.request(
|
||||
'/vehicles/addons/toggle',
|
||||
'POST',
|
||||
{
|
||||
vehicle_id: vehicleId,
|
||||
addon_id: addonId
|
||||
},
|
||||
).then(() => {
|
||||
SessionUser.request("/vehicles/addons/toggle", "POST", {
|
||||
vehicle_id: vehicleId,
|
||||
addon_id: addonId,
|
||||
}).then(() => {
|
||||
reload();
|
||||
});
|
||||
};
|
||||
@@ -108,9 +95,9 @@ const toggleVehicleAddon = (vehicleId, addonId) => {
|
||||
const getVehicleAddonToggleIcon = (vehicleAddon) => {
|
||||
// Check if the vehicle addon is applied
|
||||
if (isVehicleAddonApplied(vehicleAddon)) {
|
||||
return 'fas fa-minus';
|
||||
return "fas fa-minus";
|
||||
} else {
|
||||
return 'fas fa-plus';
|
||||
return "fas fa-plus";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,140 +124,142 @@ const getProductOptionsLabel = (vehicle) => {
|
||||
<div class="table-container" data-testid="user-vehicles-table-container">
|
||||
<table class="table is-fullwidth" data-testid="user-vehicles-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="!props.compact">{{ $t('objects.columns.id') }}</th>
|
||||
<th v-if="!props.compact">{{ $t('objects.columns.customer_id') }}</th>
|
||||
<th>{{ $t('objects.bookings.columns.reg_1') }}</th>
|
||||
<th>{{ $t('vehicles.type') }}</th>
|
||||
<th>{{ $t('objects.vehicles.columns.wash_subscription') }}</th>
|
||||
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
|
||||
<th v-if="!props.compact">{{ $t('common.reference') }}</th>
|
||||
<th v-if="!props.compact"></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th v-if="!props.compact">{{ $t("objects.columns.id") }}</th>
|
||||
<th v-if="!props.compact">{{ $t("objects.columns.customer_id") }}</th>
|
||||
<th>{{ $t("objects.bookings.columns.reg_1") }}</th>
|
||||
<th>{{ $t("vehicles.type") }}</th>
|
||||
<th>{{ $t("objects.vehicles.columns.wash_subscription") }}</th>
|
||||
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
|
||||
<th v-if="!props.compact">{{ $t("common.reference") }}</th>
|
||||
<th v-if="!props.compact"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="object in props.vehicles" :key="object.id">
|
||||
<!-- ID -->
|
||||
<EditableTableColumn
|
||||
v-if="!props.compact"
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
column="id"
|
||||
/>
|
||||
<!-- Customer ID -->
|
||||
<EditableTableColumn
|
||||
v-if="!props.compact"
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
column="customer_id"
|
||||
/>
|
||||
<!-- Reg -->
|
||||
<EditableTableColumn
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||
column="reg"
|
||||
/>
|
||||
<!-- Type -->
|
||||
<EditableTableColumn
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||
column="type"
|
||||
:parse-function="(value) => {
|
||||
return SessionUser.objects.products.functions.getProductName(value, 'Ukendt');
|
||||
}"
|
||||
/>
|
||||
<!-- Wash Subscription -->
|
||||
<EditableTableColumn
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||
column="wash_subscription"
|
||||
:parse-function="(value) => {
|
||||
return value ? t('common.yes') : t('common.no');
|
||||
}"
|
||||
/>
|
||||
<!-- Product Options, if the wash subscription is set to true -->
|
||||
<td v-if="!props.compact">
|
||||
<template v-if="object.wash_subscription">
|
||||
<!-- Enabled subscription -->
|
||||
<ActionSettingsWheelButton
|
||||
:label="getProductOptionsLabel(object)"
|
||||
:icon="SessionUser.objects.product_options.meta.icon"
|
||||
@mouseenter="getVehicleAvailableAddons(object.id, true)"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- List addons -->
|
||||
<template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id">
|
||||
<ActionSettingsWheelItem
|
||||
:label="(isVehicleAddonApplied(vehicleAddon) ? SessionUser.objects.global.language.remove : SessionUser.objects.global.language.add) + ' ' + vehicleAddon.name"
|
||||
:icon="getVehicleAddonToggleIcon(vehicleAddon)"
|
||||
:click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)"
|
||||
:template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'"
|
||||
/>
|
||||
<tr v-for="object in props.vehicles" :key="object.id">
|
||||
<!-- ID -->
|
||||
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="id" />
|
||||
<!-- Customer ID -->
|
||||
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="customer_id" />
|
||||
<!-- Reg -->
|
||||
<EditableTableColumn
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||
column="reg"
|
||||
/>
|
||||
<!-- Type -->
|
||||
<EditableTableColumn
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||
column="type"
|
||||
:parse-function="
|
||||
(value) => {
|
||||
return SessionUser.objects.products.functions.getProductName(value, 'Ukendt');
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- Wash Subscription -->
|
||||
<EditableTableColumn
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||
column="wash_subscription"
|
||||
:parse-function="
|
||||
(value) => {
|
||||
return value ? t('common.yes') : t('common.no');
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- Product Options, if the wash subscription is set to true -->
|
||||
<td v-if="!props.compact">
|
||||
<template v-if="object.wash_subscription">
|
||||
<!-- Enabled subscription -->
|
||||
<ActionSettingsWheelButton
|
||||
:label="getProductOptionsLabel(object)"
|
||||
:icon="SessionUser.objects.product_options.meta.icon"
|
||||
@mouseenter="getVehicleAvailableAddons(object.id, true)"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- List addons -->
|
||||
<template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id">
|
||||
<ActionSettingsWheelItem
|
||||
:label="
|
||||
(isVehicleAddonApplied(vehicleAddon)
|
||||
? SessionUser.objects.global.language.remove
|
||||
: SessionUser.objects.global.language.add) +
|
||||
' ' +
|
||||
vehicleAddon.name
|
||||
"
|
||||
:icon="getVehicleAddonToggleIcon(vehicleAddon)"
|
||||
:click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)"
|
||||
:template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'"
|
||||
/>
|
||||
</template>
|
||||
<!-- If there are no addons, show a message -->
|
||||
<ActionSettingsWheelItem
|
||||
v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
|
||||
:label="$t('global.no_data')"
|
||||
icon="fas fa-list"
|
||||
/>
|
||||
<!-- Addons -->
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</template>
|
||||
<!-- If there are no addons, show a message -->
|
||||
<ActionSettingsWheelItem
|
||||
v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
|
||||
:label="$t('global.no_data')"
|
||||
icon="fas fa-list"
|
||||
/>
|
||||
<!-- Addons -->
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Disabled subscription -->
|
||||
{{ $t('global.no_data') }}
|
||||
</template>
|
||||
</td>
|
||||
<!-- Reference to the vehicle -->
|
||||
<EditableTableColumn
|
||||
v-if="!props.compact"
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||
column="reference"
|
||||
/>
|
||||
<!-- Actions -->
|
||||
<td>
|
||||
<!-- Actions -->
|
||||
<ActionSettingsWheelButton
|
||||
v-if="!props.compact"
|
||||
:user_id="object.user_id"
|
||||
:reg_1="object.reg"
|
||||
:displayActionsDirectly="false"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- View (Redirect to the vehicle page) -->
|
||||
<ActionSettingsWheelItem
|
||||
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
|
||||
icon="fas fa-eye"
|
||||
:click-action="() => redirectUserVehiclePage(object.id)"
|
||||
/>
|
||||
<!-- Delete -->
|
||||
<ActionSettingsWheelItem
|
||||
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
|
||||
icon="fas fa-trash"
|
||||
:template="'danger'"
|
||||
:click-action="() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<!-- Disabled subscription -->
|
||||
{{ $t("global.no_data") }}
|
||||
</template>
|
||||
</td>
|
||||
<!-- Reference to the vehicle -->
|
||||
<EditableTableColumn
|
||||
v-if="!props.compact"
|
||||
:object="object"
|
||||
:loadList="reload"
|
||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||
column="reference"
|
||||
/>
|
||||
<!-- Actions -->
|
||||
<td>
|
||||
<!-- Actions stay grouped behind the wheel menu. -->
|
||||
<ActionSettingsWheelButton
|
||||
v-if="!props.compact"
|
||||
:user_id="object.user_id"
|
||||
:reg_1="object.reg"
|
||||
:displayActionsDirectly="false"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- View (Redirect to the vehicle page) -->
|
||||
<ActionSettingsWheelItem
|
||||
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
|
||||
icon="fas fa-eye"
|
||||
:click-action="() => redirectUserVehiclePage(object.id)"
|
||||
/>
|
||||
<!-- Delete -->
|
||||
<ActionSettingsWheelItem
|
||||
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
|
||||
icon="fas fa-trash"
|
||||
:template="'danger'"
|
||||
:click-action="
|
||||
() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="10">{{ $t('tables.showing') }} {{ props.vehicles.length }} {{ $t('objects.vehicles.multiple') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10">
|
||||
{{ $t("tables.showing") }} {{ props.vehicles.length }} {{ $t("objects.vehicles.multiple") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const employees = ref([]);
|
||||
const { t } = useI18n();
|
||||
@@ -52,6 +53,7 @@ const login = async () => {
|
||||
}
|
||||
|
||||
// Save the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', data.token);
|
||||
// Redirect to the dashboard
|
||||
window.location.href = '/admin';
|
||||
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
|
||||
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
|
||||
|
||||
if (result.token) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.token);
|
||||
window.location.href = '/admin';
|
||||
return;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseError, clearErrors, addError, getError } from "@/components/reques
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -32,6 +33,24 @@ const successMessage = ref('');
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const resolveLoginRedirectPath = () => {
|
||||
const redirectQuery = router.currentRoute.value.query.redirect;
|
||||
const redirectValue = Array.isArray(redirectQuery) ? redirectQuery[0] : redirectQuery;
|
||||
if (!redirectValue) {
|
||||
return "/redirect";
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUrl = new URL(redirectValue, window.location.origin);
|
||||
if (redirectUrl.origin !== window.location.origin) {
|
||||
return "/redirect";
|
||||
}
|
||||
return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
|
||||
} catch {
|
||||
return "/redirect";
|
||||
}
|
||||
};
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
const response = await axios.post(API_URL + '/auth/login', {
|
||||
@@ -50,13 +69,13 @@ const login = async () => {
|
||||
}
|
||||
|
||||
// Save the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', data.token);
|
||||
// Set the success message
|
||||
successMessage.value = "Du er nu logget ind!"
|
||||
// Wait 500ms before reloading the page to show the success message
|
||||
setTimeout(() => {
|
||||
// Reload the page to update the UI
|
||||
router.go(0);
|
||||
window.location.assign(resolveLoginRedirectPath());
|
||||
}, 500);
|
||||
// window.location.reload();
|
||||
} catch (e) {
|
||||
@@ -86,6 +105,7 @@ const loginWithPasskey = async () => {
|
||||
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
|
||||
console.log('Passkey authentication result:', result);
|
||||
if (result.token) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.token);
|
||||
successMessage.value = "Du er nu logget ind!";
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { parseError, getError, addError, clearErrors } from "@/components/reques
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
|
||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -39,6 +41,12 @@ const twoFactorToken = ref('');
|
||||
|
||||
const login = async () => {
|
||||
clearErrors();
|
||||
const passwordPolicyError = getSubuserPasswordPolicyError(password.value);
|
||||
if (passwordPolicyError) {
|
||||
addError(passwordPolicyError, 'auth');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let requestBody = { password: password.value };
|
||||
|
||||
@@ -62,6 +70,7 @@ const login = async () => {
|
||||
|
||||
// Save the session token
|
||||
if (data.session) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', data.session);
|
||||
localStorage.setItem('is_subuser', 'true');
|
||||
window.location.reload();
|
||||
@@ -90,6 +99,7 @@ const loginWithPasskey = async () => {
|
||||
// Subuser login returns 'session' token, user login returns 'token'
|
||||
const sessionToken = result.session || result.token;
|
||||
if (sessionToken) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', sessionToken);
|
||||
localStorage.setItem('is_subuser', 'true');
|
||||
// Reload the page to update the UI
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { verify2FA } from "@/services/TwoFactorAuthService.js";
|
||||
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -43,9 +44,11 @@ const verify = async () => {
|
||||
|
||||
// Handle the response based on user type
|
||||
if (props.userType === 'subuser' && result.session) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.session);
|
||||
localStorage.setItem('is_subuser', 'true');
|
||||
} else if (result.token) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.token);
|
||||
localStorage.removeItem('is_subuser');
|
||||
}
|
||||
|
||||
@@ -441,16 +441,23 @@ const addProductWithAddonsToOrder = async (product_id) => {
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Tilføj',
|
||||
showLoaderOnConfirm: true,
|
||||
inputValidator: (note) => {
|
||||
if (!String(note || '').trim()) {
|
||||
return 'Note er påkrævet for dette produkt';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
preConfirm: (note) => {
|
||||
const normalizedNote = String(note || '').trim();
|
||||
const confirmedOrderId = getValidOrderId();
|
||||
if (!confirmedOrderId) {
|
||||
Swal.showValidationMessage('Order ID is required');
|
||||
return false;
|
||||
}
|
||||
// Show the fake create order item
|
||||
showFakeCreateOrderItem(product_id, 1, 0, 0, note);
|
||||
showFakeCreateOrderItem(product_id, 1, 0, 0, normalizedNote);
|
||||
// Create the order item
|
||||
return createOrderItem(confirmedOrderId, product_id, 1, 0, note).then(async (result) => {
|
||||
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote).then(async (result) => {
|
||||
let order_item_id = result.data.data.id;
|
||||
// Add the addons to the order
|
||||
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
|
||||
|
||||
@@ -217,6 +217,44 @@ const getVehicleReferenceValue = () => {
|
||||
return String(vehicleObject.value?.reference ?? "").trim();
|
||||
};
|
||||
|
||||
const getVehicleStateKey = (vehicle) => {
|
||||
if (!vehicle) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
vehicle?.id ?? "",
|
||||
normalizePlateValue(vehicle?.reg),
|
||||
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
|
||||
vehicle?.type ?? "",
|
||||
vehicle?.booking_id ?? "",
|
||||
String(vehicle?.reference ?? "").trim(),
|
||||
].join("|");
|
||||
};
|
||||
|
||||
const getBookingStateKey = (booking) => {
|
||||
if (!booking) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
booking?.id ?? "",
|
||||
getBookingReg1Value(booking),
|
||||
getBookingReg2Value(booking),
|
||||
getBookingCustomerNumber(booking) ?? "",
|
||||
String(booking?.reference_number ?? booking?.reference ?? "").trim(),
|
||||
String(booking?.po ?? "").trim(),
|
||||
].join("|");
|
||||
};
|
||||
|
||||
const getBookingMatchesStateKey = (matches) => {
|
||||
if (!Array.isArray(matches) || matches.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return matches.map((booking) => getBookingStateKey(booking)).join("||");
|
||||
};
|
||||
|
||||
const setVehicleObject = (emittedVehicleObject, options = {}) => {
|
||||
const normalizedOptions = {
|
||||
preserveManualReference: true,
|
||||
@@ -224,17 +262,26 @@ const setVehicleObject = (emittedVehicleObject, options = {}) => {
|
||||
...options,
|
||||
};
|
||||
|
||||
vehicleObject.value = emittedVehicleObject;
|
||||
|
||||
const emittedVehiclePlate = normalizePlateValue(vehicleObject.value?.reg || reg_1.value);
|
||||
const emittedVehiclePlate = normalizePlateValue(emittedVehicleObject?.reg || reg_1.value);
|
||||
if (
|
||||
emittedVehiclePlate &&
|
||||
skippedDesktopBookingVehiclePlate.value === emittedVehiclePlate &&
|
||||
!isBookingMarkedVehicle(vehicleObject.value)
|
||||
!isBookingMarkedVehicle(emittedVehicleObject)
|
||||
) {
|
||||
skippedDesktopBookingVehiclePlate.value = "";
|
||||
}
|
||||
|
||||
const currentVehicleKey = getVehicleStateKey(vehicleObject.value);
|
||||
const nextVehicleKey = getVehicleStateKey(emittedVehicleObject);
|
||||
if (currentVehicleKey === nextVehicleKey) {
|
||||
if (normalizedOptions.nextSelectionSource) {
|
||||
setSelectionSource(normalizedOptions.nextSelectionSource);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
vehicleObject.value = emittedVehicleObject;
|
||||
|
||||
const matchedVehicleCustomerNumber = normalizeCustomerNumber(vehicleObject.value?.customer_id);
|
||||
const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value);
|
||||
|
||||
@@ -274,6 +321,10 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
|
||||
...options,
|
||||
};
|
||||
|
||||
if (getBookingStateKey(bookingObject.value) === getBookingStateKey(emittedBookingObject)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bookingObject.value = emittedBookingObject;
|
||||
|
||||
if (emittedBookingObject && emittedBookingObject.reference_number) {
|
||||
@@ -284,7 +335,12 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
|
||||
};
|
||||
|
||||
const setBookingMatches = (emittedBookingMatches) => {
|
||||
bookingMatches.value = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
|
||||
const nextBookingMatches = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
|
||||
if (getBookingMatchesStateKey(bookingMatches.value) === getBookingMatchesStateKey(nextBookingMatches)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bookingMatches.value = nextBookingMatches;
|
||||
};
|
||||
|
||||
const mergeBookingMatchDetails = (booking) => {
|
||||
|
||||
@@ -416,6 +416,21 @@ const shouldPreserveCustomerSelection = (plateValue) => {
|
||||
};
|
||||
|
||||
let pendingVehicleCustomerLookup = null;
|
||||
const lastAutoSyncedVehicleKey = ref("");
|
||||
|
||||
const getVehicleAutoSyncKey = (vehicle, plateOverride = null) => {
|
||||
if (!vehicle) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
resolveBookingPlate(vehicle, plateOverride),
|
||||
vehicle?.id ?? "",
|
||||
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
|
||||
normalizeCustomerNumber(customer_id.value) ?? "",
|
||||
].join("|");
|
||||
};
|
||||
|
||||
const syncMatchedVehicleSelection = (vehicle, options = {}) => {
|
||||
const normalizedOptions = {
|
||||
plateOverride: null,
|
||||
@@ -590,6 +605,7 @@ watch(reg_1, (newValue) => {
|
||||
// Define the search ID for this input change
|
||||
const search_id = register_new_search();
|
||||
console.log("reg_1 changed:", newValue);
|
||||
lastAutoSyncedVehicleKey.value = "";
|
||||
const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue);
|
||||
selectedDropdownItem.value = -1;
|
||||
// Check if the new value is empty, if so, clear the vehicles_matching array
|
||||
@@ -847,11 +863,17 @@ watch(vehicles_matching, (newValue) => {
|
||||
const currentValue = reg_1.value;
|
||||
const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue);
|
||||
if (vehicle) {
|
||||
syncMatchedVehicleSelection(vehicle);
|
||||
const nextAutoSyncKey = getVehicleAutoSyncKey(vehicle, currentValue);
|
||||
if (lastAutoSyncedVehicleKey.value !== nextAutoSyncKey) {
|
||||
lastAutoSyncedVehicleKey.value = nextAutoSyncKey;
|
||||
syncMatchedVehicleSelection(vehicle);
|
||||
}
|
||||
} else if (getBookingMatchesForSelection(null, currentValue).length > 0) {
|
||||
lastAutoSyncedVehicleKey.value = "";
|
||||
clearCustomerConflict();
|
||||
emitVehicleObject(null, currentValue);
|
||||
} else {
|
||||
lastAutoSyncedVehicleKey.value = "";
|
||||
clearCustomerConflict();
|
||||
}
|
||||
// Check if the selectedDropdownItem index is valid
|
||||
|
||||
@@ -52,6 +52,7 @@ const customer_suggestions = ref([
|
||||
const isSettingCustomer = ref(false);
|
||||
const isSettingCustomerToInteger = ref(0);
|
||||
const isSettingCustomerStartTime = ref(null);
|
||||
let customerSuggestionsRequestId = 0;
|
||||
|
||||
const isSettingCustomerTo = (customer_number) => {
|
||||
return isSettingCustomerToInteger.value === parseInt(customer_number);
|
||||
@@ -88,10 +89,17 @@ const getCustomerSuggestions = () => {
|
||||
if (!props.reg_1) {
|
||||
return;
|
||||
}
|
||||
const requestedReg1 = props.reg_1;
|
||||
const requestId = ++customerSuggestionsRequestId;
|
||||
|
||||
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
|
||||
reg_1: props.reg_1,
|
||||
reg_1: requestedReg1,
|
||||
})
|
||||
.then((response) => {
|
||||
if (requestId !== customerSuggestionsRequestId || requestedReg1 !== props.reg_1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Assuming the response contains an array of customer suggestions
|
||||
console.log("Customer suggestions:", response.data.data);
|
||||
let suggestions = [];
|
||||
@@ -126,6 +134,7 @@ watch(
|
||||
if (newValue) {
|
||||
getCustomerSuggestions();
|
||||
} else {
|
||||
customerSuggestionsRequestId += 1;
|
||||
customer_suggestions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, reactive, ref } from "vue";
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
releaseRuntimeState,
|
||||
} from "@/services/releaseTimeline.js";
|
||||
import { submitErrorReport } from "@/services/errorReports.js";
|
||||
import { errorReportLaunchRequestId } from "@/services/errorReportLauncher.js";
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
@@ -21,6 +23,9 @@ const FALLBACK_LABELS = {
|
||||
"error_report.before_error": "What were you doing before the error occurred?",
|
||||
"error_report.expected": "What did you expect would happen?",
|
||||
"error_report.actual": "What actually happened?",
|
||||
"error_report.before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
|
||||
"error_report.expected_placeholder": "Describe the result you expected to see.",
|
||||
"error_report.actual_placeholder": "Describe what you saw instead, including any error text.",
|
||||
"error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
|
||||
"error_report.submit": "Submit report",
|
||||
"error_report.submitted": "Error report submitted.",
|
||||
@@ -46,7 +51,12 @@ const form = reactive({
|
||||
data_collection_accepted: false,
|
||||
});
|
||||
|
||||
const isMobileReportPlacement = useMediaQuery("(max-width: 768px)");
|
||||
const isDesktopNavigationReportPlacement = useMediaQuery("(min-width: 1024px)");
|
||||
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
|
||||
const shouldShowFloatingButton = computed(
|
||||
() => !isMobileReportPlacement.value && !isDesktopNavigationReportPlacement.value
|
||||
);
|
||||
const isValid = computed(() => (
|
||||
form.before_error.trim().length > 0
|
||||
&& form.expected.trim().length > 0
|
||||
@@ -96,6 +106,12 @@ const open = () => {
|
||||
void loadHtml2Canvas().catch(() => {});
|
||||
};
|
||||
|
||||
watch(errorReportLaunchRequestId, () => {
|
||||
if (isAuthenticated.value) {
|
||||
open();
|
||||
}
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
if (isSubmitting.value) {
|
||||
return;
|
||||
@@ -300,6 +316,7 @@ const submit = async () => {
|
||||
<template>
|
||||
<div v-if="isAuthenticated" data-error-report-exclude>
|
||||
<button
|
||||
v-if="shouldShowFloatingButton"
|
||||
class="button is-danger error-report-button"
|
||||
type="button"
|
||||
data-testid="error-report-button"
|
||||
@@ -317,7 +334,7 @@ const submit = async () => {
|
||||
<h2 class="title is-4">{{ tr("error_report.title") }}</h2>
|
||||
<p class="subtitle is-6">{{ tr("error_report.subtitle") }}</p>
|
||||
</div>
|
||||
<button class="delete" type="button" aria-label="close" :disabled="isSubmitting" @click="close"></button>
|
||||
<button class="delete" type="button" :aria-label="t('common.close')" :disabled="isSubmitting" @click="close"></button>
|
||||
</header>
|
||||
|
||||
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
|
||||
@@ -332,17 +349,35 @@ const submit = async () => {
|
||||
|
||||
<label class="field">
|
||||
<span class="label">{{ tr("error_report.before_error") }}</span>
|
||||
<textarea v-model="form.before_error" class="textarea" maxlength="4000" required></textarea>
|
||||
<textarea
|
||||
v-model="form.before_error"
|
||||
class="textarea"
|
||||
maxlength="4000"
|
||||
:placeholder="tr('error_report.before_error_placeholder')"
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">{{ tr("error_report.expected") }}</span>
|
||||
<textarea v-model="form.expected" class="textarea" maxlength="4000" required></textarea>
|
||||
<textarea
|
||||
v-model="form.expected"
|
||||
class="textarea"
|
||||
maxlength="4000"
|
||||
:placeholder="tr('error_report.expected_placeholder')"
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">{{ tr("error_report.actual") }}</span>
|
||||
<textarea v-model="form.actual" class="textarea" maxlength="4000" required></textarea>
|
||||
<textarea
|
||||
v-model="form.actual"
|
||||
class="textarea"
|
||||
maxlength="4000"
|
||||
:placeholder="tr('error_report.actual_placeholder')"
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="checkbox error-report-consent">
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import axios from "axios";
|
||||
import { API_URL, REQUEST_QUEUE_CONFIG } from "@/config.js";
|
||||
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
|
||||
import {
|
||||
buildReleaseSessionSummary,
|
||||
getReleaseRuntimeApiBaseUrl,
|
||||
resolveReleaseApiUrl,
|
||||
} from "@/services/releaseTimeline.js";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
clearErrorRequests,
|
||||
@@ -113,6 +118,7 @@ const impersonatedUserRoleId = computed(() => {
|
||||
const canGrantMissingPermissions = computed(() =>
|
||||
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
|
||||
);
|
||||
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
|
||||
|
||||
const userDetailRows = computed(() => {
|
||||
if (SessionUser.isSubuser.value) {
|
||||
@@ -268,16 +274,24 @@ watch(recentRequests, (requests) => {
|
||||
const resolvePingUrl = () => {
|
||||
const configuredEndpoint = REQUEST_QUEUE_CONFIG.ping.endpoint;
|
||||
if (!configuredEndpoint) {
|
||||
return API_URL;
|
||||
return getReleaseRuntimeApiBaseUrl();
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(configuredEndpoint)) {
|
||||
return configuredEndpoint;
|
||||
}
|
||||
|
||||
return `${API_URL.replace(/\/+$/, "")}/${String(configuredEndpoint).replace(/^\/+/, "")}`;
|
||||
return resolveReleaseApiUrl(configuredEndpoint);
|
||||
};
|
||||
|
||||
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
|
||||
const activeApiUrlLabel = computed(() =>
|
||||
canInspectReleaseRuntime.value ? activeApiUrl.value : "Restricted to release operators"
|
||||
);
|
||||
const releaseSessionSummary = computed(() =>
|
||||
buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: canInspectReleaseRuntime.value })
|
||||
);
|
||||
|
||||
const measurePingLatency = async () => {
|
||||
if (typeof fetch !== "function") {
|
||||
pingLatencyMs.value = null;
|
||||
@@ -404,7 +418,7 @@ const grantMissingPermission = async (permission) => {
|
||||
setPermissionGrantStatus(normalizedPermission, "loading");
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_URL}/roles/permissions`,
|
||||
resolveReleaseApiUrl("/roles/permissions"),
|
||||
{
|
||||
group_id: roleId,
|
||||
permission_id: normalizedPermission,
|
||||
@@ -638,60 +652,191 @@ onBeforeUnmount(() => {
|
||||
</aside>
|
||||
|
||||
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
|
||||
<div class="request-queue-progress__section-title">Runtime</div>
|
||||
<ul class="request-queue-progress__meta-list request-queue-progress__section-content request-queue-progress__section-content--meta">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">API URL</span>
|
||||
<span class="request-queue-progress__meta-value" :title="API_URL">{{ API_URL }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Current host</span>
|
||||
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Environment</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Commit</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version time</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
|
||||
<span class="request-queue-progress__meta-label">i18n catalog</span>
|
||||
<button
|
||||
class="request-queue-progress__catalog-switch"
|
||||
data-testid="request-queue-i18n-catalog-switch"
|
||||
type="button"
|
||||
@click="handleToggleI18nCatalogVersion"
|
||||
<template v-if="canInspectReleaseRuntime">
|
||||
<div class="request-queue-progress__section-title">Session release</div>
|
||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Channel</span>
|
||||
<span
|
||||
class="request-queue-progress__meta-value"
|
||||
:title="releaseSessionSummary.channelSlug || releaseSessionSummary.channelLabel"
|
||||
>
|
||||
{{ releaseSessionSummary.channelLabel }}
|
||||
<span v-if="releaseSessionSummary.channelSlug">({{ releaseSessionSummary.channelSlug }})</span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Trace ID</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.traceId">
|
||||
{{ releaseSessionSummary.traceId }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Generated</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.generatedAt">
|
||||
{{ releaseSessionSummary.generatedAt }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Bundle</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.bundleLabel">
|
||||
{{ releaseSessionSummary.bundleLabel }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Service set</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.serviceSetLabel">
|
||||
{{ releaseSessionSummary.serviceSetLabel }}
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="releaseSessionSummary.missingLabels.length > 0"
|
||||
class="request-queue-progress__meta-item"
|
||||
data-testid="request-queue-release-missing"
|
||||
>
|
||||
{{ i18nCatalogVersionLabel }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing requests</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing responses</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="request-queue-progress__meta-label">Missing</span>
|
||||
<span
|
||||
class="request-queue-progress__meta-value request-queue-progress__meta-value--warning"
|
||||
:title="releaseSessionSummary.missingLabels.join(', ')"
|
||||
>
|
||||
{{ releaseSessionSummary.missingLabels.join(", ") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">App releases</div>
|
||||
<ul class="request-queue-progress__release-list">
|
||||
<li
|
||||
v-for="app in releaseSessionSummary.appRows"
|
||||
:key="app.key"
|
||||
class="request-queue-progress__release-row"
|
||||
:data-testid="`request-queue-release-app-${app.key}`"
|
||||
>
|
||||
<div class="request-queue-progress__release-row-header">
|
||||
<strong>{{ app.label }}</strong>
|
||||
<span
|
||||
class="request-queue-progress__status-pill"
|
||||
:class="`request-queue-progress__status-pill--${app.tone}`"
|
||||
>
|
||||
{{ app.status }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="request-queue-progress__release-primary" :title="app.title">
|
||||
{{ app.primaryText }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.secondaryText"
|
||||
class="request-queue-progress__release-secondary"
|
||||
:title="app.secondaryText"
|
||||
>
|
||||
{{ app.secondaryText }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.url"
|
||||
class="request-queue-progress__release-url"
|
||||
:title="app.url"
|
||||
>
|
||||
{{ app.url }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.missingLabel"
|
||||
class="request-queue-progress__release-secondary request-queue-progress__release-secondary--warning"
|
||||
>
|
||||
{{ app.missingLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">Connected services</div>
|
||||
<ul class="request-queue-progress__release-list">
|
||||
<li
|
||||
v-for="service in releaseSessionSummary.serviceRows"
|
||||
:key="service.key"
|
||||
class="request-queue-progress__release-row"
|
||||
:data-testid="`request-queue-release-service-${service.key}`"
|
||||
>
|
||||
<div class="request-queue-progress__release-row-header">
|
||||
<strong>{{ service.label }}</strong>
|
||||
<span
|
||||
class="request-queue-progress__status-pill"
|
||||
:class="`request-queue-progress__status-pill--${service.tone}`"
|
||||
>
|
||||
{{ service.status }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="request-queue-progress__release-primary" :title="service.title">
|
||||
{{ service.primaryText }}
|
||||
</span>
|
||||
<span
|
||||
v-if="service.secondaryText"
|
||||
class="request-queue-progress__release-secondary"
|
||||
:title="service.secondaryText"
|
||||
>
|
||||
{{ service.secondaryText }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="request-queue-progress__section-title">Runtime details</div>
|
||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">API URL</span>
|
||||
<span class="request-queue-progress__meta-value" :title="activeApiUrlLabel">{{ activeApiUrlLabel }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Current host</span>
|
||||
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Environment</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Commit</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version time</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
|
||||
<span class="request-queue-progress__meta-label">i18n catalog</span>
|
||||
<button
|
||||
class="request-queue-progress__catalog-switch"
|
||||
data-testid="request-queue-i18n-catalog-switch"
|
||||
type="button"
|
||||
@click="handleToggleI18nCatalogVersion"
|
||||
>
|
||||
{{ i18nCatalogVersionLabel }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing requests</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing responses</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="request-queue-progress__side request-queue-progress__side--user" data-testid="request-queue-user-box">
|
||||
@@ -928,6 +1073,12 @@ onBeforeUnmount(() => {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.request-queue-progress__section-content--release {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.request-queue-progress__section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -1167,6 +1318,108 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__meta-value--warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.request-queue-progress__subsection-title {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-row {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 7px;
|
||||
background: rgba(2, 6, 23, 0.28);
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-row-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-row-header strong {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill {
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
padding: 3px 7px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill--ok {
|
||||
background: rgba(94, 234, 212, 0.16);
|
||||
color: #99f6e4;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill--warning {
|
||||
background: rgba(251, 191, 36, 0.16);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill--danger {
|
||||
background: rgba(248, 113, 113, 0.16);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-primary,
|
||||
.request-queue-progress__release-secondary,
|
||||
.request-queue-progress__release-url {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-primary {
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-secondary,
|
||||
.request-queue-progress__release-url {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-secondary--warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.request-queue-progress__catalog-switch {
|
||||
border: 1px solid rgba(126, 249, 227, 0.5);
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -86,6 +86,13 @@ const menu_items = ref([
|
||||
children: [],
|
||||
hidden: false
|
||||
},
|
||||
{
|
||||
label: 'Chauffører',
|
||||
value: '/subusers',
|
||||
icon: 'fas fa-id-card',
|
||||
children: [],
|
||||
hidden: false
|
||||
},
|
||||
{
|
||||
label: SessionUser.objects.roles.meta.title,
|
||||
value: SessionUser.objects.roles.meta.endpoint,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import MenuDefault from "@/components/menus/MenuDefault.vue";
|
||||
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
|
||||
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
|
||||
import {ref, computed} from "vue";
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -70,9 +71,10 @@ const menu_items = computed(() => [
|
||||
:show_icons="false"
|
||||
/>
|
||||
<LanguageSelector />
|
||||
<ReleaseChannelSidebarSelector />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -332,6 +332,7 @@ const items = computed<NavigationItemProps[]>(() => [
|
||||
type: "category",
|
||||
children: [
|
||||
{ label: t("superuser.nav.employees"), to: "/superuser/users" },
|
||||
{ label: "Chauffører", to: "/superuser/subusers" },
|
||||
{ label: t("superuser.nav.customers"), to: "/superuser/customers" },
|
||||
{ label: t("superuser.nav.complaints"), to: "/superuser/complaints" },
|
||||
{ label: t("superuser.nav.roles"), to: "/superuser/roles" },
|
||||
|
||||
@@ -3,38 +3,80 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { ref } from 'vue';
|
||||
defineProps(['hasPermission']);
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { pingApiServer } from "@/services/apiHealth.js";
|
||||
|
||||
const props = defineProps({
|
||||
hasPermission: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const showDebugInfo = ref(false);
|
||||
const isSessionInitiated = computed(() => SessionUser.isInitiated());
|
||||
let sessionTimeout = null;
|
||||
|
||||
// If the user is not initiated, and it takes more than 5 seconds, show an error message
|
||||
setTimeout(() => {
|
||||
const clearSessionTimeout = () => {
|
||||
if (sessionTimeout) {
|
||||
clearTimeout(sessionTimeout);
|
||||
sessionTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const redirectToConnectivityIssue = () => {
|
||||
if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = "/connectivity-issue";
|
||||
};
|
||||
|
||||
const handleSlowSessionBootstrap = async () => {
|
||||
if (!SessionUser.isInitiated()) {
|
||||
Swal.fire({
|
||||
const pingResult = await pingApiServer();
|
||||
if (!pingResult.ok) {
|
||||
redirectToConnectivityIssue();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: 'Error',
|
||||
text: 'The user session could not be initiated.',
|
||||
icon: 'error',
|
||||
confirmButtonText: 'Clear session, and try again',
|
||||
showCancelButton: true,
|
||||
cancelButtonText: 'Retry',
|
||||
}).then(() => {
|
||||
SessionUser.auth.forceClearSession();
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
SessionUser.auth.forceClearSession();
|
||||
SessionUser.functions.redirectTo.pages.login();
|
||||
return;
|
||||
}
|
||||
|
||||
await SessionUser.initiateOnAppStart({ force: true });
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
sessionTimeout = setTimeout(() => {
|
||||
void handleSlowSessionBootstrap();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(clearSessionTimeout);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!SessionUser.isInitiated()">
|
||||
<div v-if="!isSessionInitiated">
|
||||
<div class="pageloader is-active is-dark">
|
||||
<span class="title">Bekræfter bruger...</span>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-if="hasPermission" />
|
||||
<slot v-else-if="props.hasPermission" />
|
||||
<div v-else>
|
||||
<div class="my-6">
|
||||
<h2 class="title is-2">403 Forbidden</h2>
|
||||
@@ -76,4 +118,4 @@ setTimeout(() => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -48,10 +48,13 @@ export const getDepartmentDescription = (id) => {
|
||||
|
||||
export const getDepartmentsGuest = async (queryParams = {}) => {
|
||||
isLoading.value = true;
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
|
||||
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
|
||||
isLoading.value = false;
|
||||
return departments.value;
|
||||
try {
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
|
||||
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
|
||||
return departments.value;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script>
|
||||
import { ref, inject, provide } from 'vue';
|
||||
import { getCurrentScope, inject, onScopeDispose, provide, ref } from 'vue';
|
||||
import axios from "axios";
|
||||
import {API_URL} from "@/config.js";
|
||||
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
||||
import { exportRowsToExcel } from "@/services/TableExcelExportService.js";
|
||||
|
||||
export const PaginatedListKey = Symbol('PaginatedList');
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
/**
|
||||
* usePaginatedList composable
|
||||
@@ -33,6 +34,9 @@ export function usePaginatedList() {
|
||||
|
||||
/** The latest search */
|
||||
const latestSearch = ref(null);
|
||||
const latestRequestId = ref(0);
|
||||
let activeRequestController = null;
|
||||
let searchDebounceTimeout = null;
|
||||
|
||||
/** Additional query parameters */
|
||||
const additionalQueryParameters = ref({});
|
||||
@@ -124,24 +128,58 @@ export function usePaginatedList() {
|
||||
return search === latestSearch.value;
|
||||
};
|
||||
|
||||
const clearPendingSearch = () => {
|
||||
if (searchDebounceTimeout) {
|
||||
clearTimeout(searchDebounceTimeout);
|
||||
searchDebounceTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const abortActiveRequest = () => {
|
||||
activeRequestController?.abort();
|
||||
activeRequestController = null;
|
||||
};
|
||||
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(() => {
|
||||
clearPendingSearch();
|
||||
abortActiveRequest();
|
||||
});
|
||||
}
|
||||
|
||||
const isCanceledRequest = (error) => {
|
||||
return error?.code === "ERR_CANCELED"
|
||||
|| error?.name === "CanceledError"
|
||||
|| error?.name === "AbortError"
|
||||
|| (typeof axios.isCancel === "function" && axios.isCancel(error));
|
||||
};
|
||||
|
||||
/** The paginated get request */
|
||||
const paginatedGetRequest = async () => {
|
||||
// Set the latest search
|
||||
latestSearch.value = metaSearch.value;
|
||||
const tmp_search = metaSearch.value;
|
||||
const requestId = latestRequestId.value + 1;
|
||||
latestRequestId.value = requestId;
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
loadSwitch(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
abortActiveRequest();
|
||||
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
|
||||
activeRequestController = requestController;
|
||||
|
||||
try {
|
||||
const response = await axios.get(API_URL + endpoint.value, {
|
||||
params: buildRequestParams(),
|
||||
headers: buildRequestHeaders(token)
|
||||
headers: buildRequestHeaders(token),
|
||||
...(requestController ? { signal: requestController.signal } : {}),
|
||||
});
|
||||
|
||||
// Check if the search is the latest search
|
||||
if (!isLatestSearch(tmp_search)) {
|
||||
if (!isLatestSearch(tmp_search) || requestId !== latestRequestId.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -152,22 +190,30 @@ export function usePaginatedList() {
|
||||
response?.data?.meta?.pagination?.total || 0
|
||||
);
|
||||
meta.value = response?.data?.meta || null;
|
||||
loadSwitch(false);
|
||||
setLastUpdated();
|
||||
return response;
|
||||
} catch (error) {
|
||||
parseError(error, 'paginatedGetRequest');
|
||||
console.log(error);
|
||||
loadSwitch(false);
|
||||
if (!isCanceledRequest(error)) {
|
||||
parseError(error, 'paginatedGetRequest');
|
||||
console.log(error);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (activeRequestController === requestController) {
|
||||
activeRequestController = null;
|
||||
}
|
||||
if (requestId === latestRequestId.value) {
|
||||
loadSwitch(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** The load list function */
|
||||
const loadList = () => {
|
||||
clearPendingSearch();
|
||||
loadSwitch(true);
|
||||
// clear the list
|
||||
paginatedGetRequest();
|
||||
return paginatedGetRequest();
|
||||
};
|
||||
|
||||
/** Set the page */
|
||||
@@ -254,11 +300,17 @@ export function usePaginatedList() {
|
||||
const search = (searchValue, autoLoad = true) => {
|
||||
// If the search is *, remove the search
|
||||
metaSearch.value = searchValue === '*' ? null : searchValue;
|
||||
latestSearch.value = metaSearch.value;
|
||||
// Reset the page to 1
|
||||
setPage(1);
|
||||
// Load the list
|
||||
if (autoLoad) {
|
||||
loadList();
|
||||
clearPendingSearch();
|
||||
loadSwitch(true);
|
||||
searchDebounceTimeout = setTimeout(() => {
|
||||
searchDebounceTimeout = null;
|
||||
paginatedGetRequest();
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
|
||||
defineProps({
|
||||
testid: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSettingsWheelButton class="release-action-menu" :data-testid="testid || undefined">
|
||||
<template #actions>
|
||||
<slot />
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</template>
|
||||
@@ -0,0 +1,404 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { BAutocomplete } from "buefy";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { searchReleaseAssignmentSubjects } from "@/services/superuserReleases.js";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
debounceMs: {
|
||||
type: Number,
|
||||
default: 250,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "select", "clear"]);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
const tr = (key, params = {}) => t(`configuration.release_manager.assignments.${key}`, params);
|
||||
const valueLabel = (type) => {
|
||||
const key = `configuration.release_manager.values.subject_type.${type}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? type : translated;
|
||||
};
|
||||
|
||||
const SUBJECT_TYPES = ["user", "subuser", "customer"];
|
||||
const autocompleteRef = ref(null);
|
||||
const typedSubject = ref("");
|
||||
const remoteOptions = ref([]);
|
||||
const selectedOption = ref(null);
|
||||
const isFetching = ref(false);
|
||||
|
||||
let debounceTimer = null;
|
||||
let requestSequence = 0;
|
||||
|
||||
const subjectIcon = (type) => {
|
||||
if (type === "customer") {
|
||||
return "fas fa-building";
|
||||
}
|
||||
if (type === "subuser") {
|
||||
return "fas fa-id-badge";
|
||||
}
|
||||
return "fas fa-user";
|
||||
};
|
||||
|
||||
const normalizeOption = (option) => {
|
||||
if (!option || typeof option !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subjectType = String(option.subject_type || "").trim().toLowerCase();
|
||||
const subjectId = String(option.subject_id || "").trim();
|
||||
if (!SUBJECT_TYPES.includes(subjectType) || !subjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = String(option.title || `${valueLabel(subjectType)} #${subjectId}`).trim();
|
||||
const description = String(option.description || "").trim();
|
||||
return {
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
label: String(option.label || (description ? `${title} - ${description}` : title)).trim(),
|
||||
title,
|
||||
description,
|
||||
icon: String(option.icon || subjectIcon(subjectType)).trim(),
|
||||
source: String(option.source || subjectType).trim(),
|
||||
group: option.group || subjectType,
|
||||
manual: Boolean(option.manual),
|
||||
};
|
||||
};
|
||||
|
||||
const manualTitle = (type, id) => {
|
||||
if (type === "customer") {
|
||||
return tr("manual_customer", { id });
|
||||
}
|
||||
if (type === "subuser") {
|
||||
return tr("manual_subuser", { id });
|
||||
}
|
||||
return tr("manual_user", { id });
|
||||
};
|
||||
|
||||
const manualOptions = computed(() => {
|
||||
const query = typedSubject.value.trim();
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const explicit = query.match(/^(user|subuser|customer):([a-zA-Z0-9_.:-]{1,64})$/i);
|
||||
const subjects = explicit
|
||||
? [{ type: explicit[1].toLowerCase(), id: explicit[2] }]
|
||||
: /^\d{1,64}$/.test(query)
|
||||
? SUBJECT_TYPES.map((type) => ({ type, id: query }))
|
||||
: [];
|
||||
|
||||
return subjects
|
||||
.map(({ type, id }) =>
|
||||
normalizeOption({
|
||||
subject_type: type,
|
||||
subject_id: id,
|
||||
title: manualTitle(type, id),
|
||||
description: tr("manual_description", { subject: `${type}:${id}` }),
|
||||
icon: subjectIcon(type),
|
||||
source: "manual",
|
||||
group: "manual",
|
||||
manual: true,
|
||||
})
|
||||
)
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const groupLabel = (group) => {
|
||||
if (group === "manual") {
|
||||
return tr("group_manual");
|
||||
}
|
||||
if (group === "customer") {
|
||||
return tr("group_customers");
|
||||
}
|
||||
if (group === "subuser") {
|
||||
return tr("group_subusers");
|
||||
}
|
||||
return tr("group_users");
|
||||
};
|
||||
|
||||
const groupedOptions = computed(() => {
|
||||
const seen = new Set();
|
||||
const grouped = new Map();
|
||||
const order = ["user", "subuser", "customer", "manual"];
|
||||
|
||||
[...remoteOptions.value, ...manualOptions.value].forEach((option) => {
|
||||
const normalized = normalizeOption(option);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${normalized.subject_type}:${normalized.subject_id}`;
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
const group = normalized.group || normalized.subject_type;
|
||||
if (!grouped.has(group)) {
|
||||
grouped.set(group, []);
|
||||
}
|
||||
grouped.get(group).push(normalized);
|
||||
});
|
||||
|
||||
return order
|
||||
.filter((group) => grouped.has(group))
|
||||
.map((group) => ({
|
||||
group: groupLabel(group),
|
||||
key: group,
|
||||
items: grouped.get(group),
|
||||
}));
|
||||
});
|
||||
|
||||
const syncInputAttributes = async () => {
|
||||
await nextTick();
|
||||
const input = autocompleteRef.value?.$el?.querySelector?.("input");
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.setAttribute("data-testid", "release-assignment-subject-search");
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.setAttribute("aria-label", tr("subject"));
|
||||
};
|
||||
|
||||
const fetchOptions = async (query) => {
|
||||
const search = String(query || "").trim();
|
||||
if (!search) {
|
||||
remoteOptions.value = [];
|
||||
isFetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRequest = ++requestSequence;
|
||||
isFetching.value = true;
|
||||
try {
|
||||
const response = await searchReleaseAssignmentSubjects({ search, limit: props.limit });
|
||||
if (currentRequest !== requestSequence) {
|
||||
return;
|
||||
}
|
||||
const options = Array.isArray(response?.data?.data) ? response.data.data : [];
|
||||
remoteOptions.value = options.map(normalizeOption).filter(Boolean);
|
||||
} catch {
|
||||
if (currentRequest === requestSequence) {
|
||||
remoteOptions.value = [];
|
||||
}
|
||||
} finally {
|
||||
if (currentRequest === requestSequence) {
|
||||
isFetching.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleFetch = (query) => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
|
||||
if (props.debounceMs <= 0) {
|
||||
void fetchOptions(query);
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
void fetchOptions(query);
|
||||
}, props.debounceMs);
|
||||
};
|
||||
|
||||
const handleTyping = (value) => {
|
||||
typedSubject.value = String(value || "");
|
||||
if (selectedOption.value && typedSubject.value !== selectedOption.value.label) {
|
||||
selectedOption.value = null;
|
||||
emit("clear");
|
||||
}
|
||||
scheduleFetch(typedSubject.value);
|
||||
void syncInputAttributes();
|
||||
};
|
||||
|
||||
const handleSelect = (option) => {
|
||||
const normalized = normalizeOption(option);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedOption.value = normalized;
|
||||
typedSubject.value = normalized.label;
|
||||
emit("update:modelValue", normalized);
|
||||
emit("select", normalized);
|
||||
void syncInputAttributes();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(nextValue) => {
|
||||
const normalized = normalizeOption(nextValue);
|
||||
selectedOption.value = normalized;
|
||||
typedSubject.value = normalized?.label || "";
|
||||
void syncInputAttributes();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void syncInputAttributes();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
requestSequence++;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="release-assignment-subject" data-testid="release-assignment-subject-autocomplete">
|
||||
<BAutocomplete
|
||||
ref="autocompleteRef"
|
||||
v-model="typedSubject"
|
||||
:data="groupedOptions"
|
||||
field="label"
|
||||
group-field="group"
|
||||
group-options="items"
|
||||
:placeholder="tr('subject_placeholder')"
|
||||
icon="search"
|
||||
icon-pack="fas"
|
||||
:loading="isFetching"
|
||||
open-on-focus
|
||||
expanded
|
||||
keep-first
|
||||
max-height="320"
|
||||
@typing="handleTyping"
|
||||
@select="handleSelect"
|
||||
@focus="syncInputAttributes"
|
||||
>
|
||||
<template #group="{ group, index }">
|
||||
<span
|
||||
class="release-assignment-subject__group"
|
||||
:data-testid="`release-assignment-subject-group-${groupedOptions[index]?.key || 'other'}`"
|
||||
>
|
||||
{{ group }}
|
||||
</span>
|
||||
</template>
|
||||
<template #default="slotProps">
|
||||
<div
|
||||
class="release-assignment-subject__option"
|
||||
:data-testid="
|
||||
slotProps.option.manual
|
||||
? `release-assignment-subject-manual-${slotProps.option.subject_type}-${slotProps.option.subject_id}`
|
||||
: `release-assignment-subject-option-${slotProps.option.subject_type}-${slotProps.option.subject_id}`
|
||||
"
|
||||
>
|
||||
<span class="release-assignment-subject__icon">
|
||||
<i :class="slotProps.option.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="release-assignment-subject__body">
|
||||
<strong>{{ slotProps.option.title }}</strong>
|
||||
<small>{{ slotProps.option.description }}</small>
|
||||
</span>
|
||||
<b-tag size="is-small">{{ valueLabel(slotProps.option.subject_type) }}</b-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<span class="release-assignment-subject__empty">{{ tr("subject_empty") }}</span>
|
||||
</template>
|
||||
</BAutocomplete>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-assignment-subject {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.release-assignment-subject :deep(.autocomplete),
|
||||
.release-assignment-subject :deep(.control) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.release-assignment-subject :deep(.dropdown-content) {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 12px 28px rgba(31, 45, 61, 0.16);
|
||||
max-width: min(760px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.release-assignment-subject :deep(.dropdown-item) {
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.release-assignment-subject__group {
|
||||
background: #f7f9fc;
|
||||
color: #5f6b7c;
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
padding: 8px 12px 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-assignment-subject__option {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.release-assignment-subject__icon {
|
||||
align-items: center;
|
||||
background: #eef3fb;
|
||||
border-radius: 50%;
|
||||
color: #243957;
|
||||
display: inline-flex;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.release-assignment-subject__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.release-assignment-subject__body strong,
|
||||
.release-assignment-subject__body small {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.release-assignment-subject__body strong {
|
||||
color: #253047;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.release-assignment-subject__body small,
|
||||
.release-assignment-subject__empty {
|
||||
color: #758195;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.release-assignment-subject__empty {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,450 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
releaseChannelKey,
|
||||
releaseChannelOptions,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
import ReleaseFrontendVersionBadge from "@/components/release/ReleaseFrontendVersionBadge.vue";
|
||||
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "panel",
|
||||
},
|
||||
switchingSlug: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
|
||||
const { t, te, locale } = useI18n({ useScope: "global" });
|
||||
const tr = (key, fallback, params = {}) => {
|
||||
const path = `configuration.release_manager.channel_selector.${key}`;
|
||||
return te(path) ? t(path, params) : fallback;
|
||||
};
|
||||
|
||||
const activeSlug = computed(() => releaseChannelKey(releaseRuntimeState.channel));
|
||||
const isSidebar = computed(() => props.variant === "sidebar");
|
||||
const selectorTitle = computed(() =>
|
||||
isSidebar.value ? tr("sidebar_title", "Release") : tr("title", "Release channel")
|
||||
);
|
||||
const selectorSubtitle = computed(() =>
|
||||
isSidebar.value ? "" : tr("subtitle", "Choose which assigned release channel this device should use.")
|
||||
);
|
||||
|
||||
const optionStateLabel = (option) => {
|
||||
if (option.defaultChannel) {
|
||||
return tr("default", "Default");
|
||||
}
|
||||
return option.configured ? tr("ready", "Ready") : tr("unavailable", "Not ready");
|
||||
};
|
||||
|
||||
const missingLabel = (key) => {
|
||||
if (key === "release_bundle") return tr("release_bundle", "Release bundle");
|
||||
if (key === "frontend_version") return tr("frontend_version", "Frontend version");
|
||||
if (key === "api_version") return tr("api_version", "API version");
|
||||
if (key === "frontend_base_url") return tr("frontend_base_url", "Frontend URL");
|
||||
if (key === "api_base_url") return tr("api_base_url", "API URL");
|
||||
if (key === "frontend_entry") return tr("frontend_entry", "Frontend entry");
|
||||
if (key === "release_runtime") return tr("release_runtime", "Release runtime");
|
||||
return key;
|
||||
};
|
||||
|
||||
const textValue = (value) => String(value || "").trim();
|
||||
const shortCommit = (value) => textValue(value).slice(0, 12);
|
||||
const githubAccess = (version) =>
|
||||
version?.metadata?.github_access && typeof version.metadata.github_access === "object"
|
||||
? version.metadata.github_access
|
||||
: {};
|
||||
const githubCommit = (version) => {
|
||||
const access = githubAccess(version);
|
||||
return version?.commit && typeof version.commit === "object"
|
||||
? version.commit
|
||||
: access.commit || access.latest_commit || null;
|
||||
};
|
||||
const versionCommit = (version) => {
|
||||
const commit = githubCommit(version);
|
||||
return (
|
||||
textValue(version?.commit_sha) ||
|
||||
(typeof version?.commit === "string" ? textValue(version.commit) : "") ||
|
||||
textValue(commit?.sha) ||
|
||||
textValue(githubAccess(version)?.commit?.sha) ||
|
||||
textValue(githubAccess(version)?.latest_commit?.sha)
|
||||
);
|
||||
};
|
||||
const versionTimestamp = (version) => {
|
||||
const commit = githubCommit(version);
|
||||
return (
|
||||
textValue(version?.deployed_at) ||
|
||||
textValue(version?.released_at) ||
|
||||
textValue(version?.promoted_at) ||
|
||||
textValue(version?.created_at) ||
|
||||
textValue(version?.commit_authored_at) ||
|
||||
textValue(commit?.authored_at) ||
|
||||
textValue(githubAccess(version)?.commit_authored_at)
|
||||
);
|
||||
};
|
||||
const formatReleaseTime = (value) => {
|
||||
const timestamp = textValue(value);
|
||||
if (!timestamp) {
|
||||
return "";
|
||||
}
|
||||
const parsed = new Date(timestamp);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return timestamp;
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale.value || undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(parsed);
|
||||
};
|
||||
const appReleaseDetail = (option, app) => {
|
||||
const version = option?.versions?.[app] || null;
|
||||
if (!version) {
|
||||
return null;
|
||||
}
|
||||
const commit = versionCommit(version);
|
||||
const timestamp = versionTimestamp(version);
|
||||
const value = shortCommit(commit) || textValue(version?.version_label) || textValue(version?.label);
|
||||
if (!value && !timestamp) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: app,
|
||||
label: app === "api" ? tr("api", "API") : tr("frontend", "Frontend"),
|
||||
value,
|
||||
timestamp: formatReleaseTime(timestamp),
|
||||
};
|
||||
};
|
||||
const bundleReleaseDetail = (option) => {
|
||||
const bundle = option?.versions?.bundle || null;
|
||||
const bundleId = textValue(bundle?.id || option?.versions?.bundle_id);
|
||||
const timestamp = textValue(bundle?.promoted_at) || textValue(bundle?.deployed_at) || textValue(bundle?.created_at);
|
||||
if (!bundleId && !timestamp) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: "bundle",
|
||||
label: tr("bundle", "Bundle"),
|
||||
value: bundleId ? `#${bundleId}` : "",
|
||||
timestamp: formatReleaseTime(timestamp),
|
||||
};
|
||||
};
|
||||
const optionReleaseDetails = (option) =>
|
||||
[bundleReleaseDetail(option), appReleaseDetail(option, "frontend"), appReleaseDetail(option, "api")].filter(Boolean);
|
||||
|
||||
const optionTestId = (option) => `release-channel-option-${option.channelSlug || "unknown"}`;
|
||||
|
||||
const selectOption = (option) => {
|
||||
if (props.disabled || props.switchingSlug || option.channelSlug === activeSlug.value) {
|
||||
return;
|
||||
}
|
||||
emit("select", option);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="release-channel-selector"
|
||||
:class="[`release-channel-selector--${variant}`]"
|
||||
data-testid="release-channel-selector"
|
||||
>
|
||||
<div class="release-channel-selector__header">
|
||||
<div class="release-channel-selector__header-main">
|
||||
<p>{{ selectorTitle }}</p>
|
||||
<ReleaseFrontendVersionBadge v-if="isSidebar" />
|
||||
</div>
|
||||
<span v-if="selectorSubtitle">{{ selectorSubtitle }}</span>
|
||||
</div>
|
||||
|
||||
<div class="release-channel-selector__options">
|
||||
<button
|
||||
v-for="option in releaseChannelOptions"
|
||||
:key="option.channelSlug"
|
||||
type="button"
|
||||
class="release-channel-option"
|
||||
:class="{
|
||||
'release-channel-option--active': option.channelSlug === activeSlug,
|
||||
'release-channel-option--unavailable': !option.configured,
|
||||
}"
|
||||
:aria-pressed="option.channelSlug === activeSlug"
|
||||
:disabled="disabled || Boolean(switchingSlug)"
|
||||
:data-testid="optionTestId(option)"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="release-channel-option__main">
|
||||
<span class="release-channel-option__icon" aria-hidden="true">
|
||||
<i class="fas" :class="option.defaultChannel ? 'fa-shield-alt' : 'fa-code-branch'"></i>
|
||||
</span>
|
||||
<span class="release-channel-option__copy">
|
||||
<strong>{{ option.channelName }}</strong>
|
||||
<small>{{ optionStateLabel(option) }}</small>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="release-channel-option__meta">
|
||||
<span v-if="switchingSlug === option.channelSlug" class="release-channel-option__spinner" aria-hidden="true">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</span>
|
||||
<span v-else-if="option.channelSlug === activeSlug" class="release-channel-option__check" aria-hidden="true">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<span v-else class="release-channel-option__arrow" aria-hidden="true">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span v-if="!isSidebar && !option.configured && option.missing.length" class="release-channel-option__missing">
|
||||
<span v-for="item in option.missing" :key="item">{{ missingLabel(item) }}</span>
|
||||
</span>
|
||||
|
||||
<span v-if="!isSidebar && optionReleaseDetails(option).length" class="release-channel-option__release-details">
|
||||
<span
|
||||
v-for="detail in optionReleaseDetails(option)"
|
||||
:key="detail.key"
|
||||
class="release-channel-option__release-detail"
|
||||
:data-testid="`${optionTestId(option)}-${detail.key}-release`"
|
||||
>
|
||||
<strong>{{ detail.label }}</strong>
|
||||
<span v-if="detail.value">{{ detail.value }}</span>
|
||||
<small v-if="detail.timestamp">{{ detail.timestamp }}</small>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-channel-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.release-channel-selector--panel {
|
||||
margin-top: 24px;
|
||||
padding: 18px;
|
||||
border: 1px solid #d9e4f2;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.release-channel-selector--sidebar {
|
||||
padding: 0 16px 14px;
|
||||
}
|
||||
|
||||
.release-channel-selector__header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.release-channel-selector__header-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.release-channel-selector__header p {
|
||||
margin: 0;
|
||||
color: #10243f;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-channel-selector__header span {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.release-channel-selector--sidebar .release-channel-selector__header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.release-channel-selector__options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.release-channel-option {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d7e2ef;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.release-channel-option:hover:not(:disabled) {
|
||||
border-color: #8fb1d6;
|
||||
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.release-channel-option:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.release-channel-option--active {
|
||||
border-color: #153554;
|
||||
background: #f8fbff;
|
||||
box-shadow: inset 3px 0 0 #153554;
|
||||
}
|
||||
|
||||
.release-channel-option--unavailable:not(.release-channel-option--active) {
|
||||
border-color: #f4cc7a;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.release-channel-option__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.release-channel-option__icon,
|
||||
.release-channel-option__check,
|
||||
.release-channel-option__arrow,
|
||||
.release-channel-option__spinner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.release-channel-option__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #eaf2fb;
|
||||
color: #005486;
|
||||
}
|
||||
|
||||
.release-channel-option__copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.release-channel-option__copy strong,
|
||||
.release-channel-option__copy small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.release-channel-option__copy strong {
|
||||
color: #172033;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.release-channel-option__copy small {
|
||||
color: #64748b;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-channel-option__meta {
|
||||
color: #153554;
|
||||
}
|
||||
|
||||
.release-channel-option__missing {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
.release-channel-option__missing span {
|
||||
padding: 4px 7px;
|
||||
border: 1px solid #f6c56b;
|
||||
background: #fff7e6;
|
||||
color: #704600;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.release-channel-option__release-details {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 6px;
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
.release-channel-option__release-detail {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #d9e4f2;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.release-channel-option__release-detail strong {
|
||||
color: #005486;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-channel-option__release-detail span {
|
||||
color: #172033;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.release-channel-option__release-detail small {
|
||||
color: #64748b;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.release-channel-selector--sidebar .release-channel-option {
|
||||
padding: 9px 10px;
|
||||
border-radius: 5px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.release-channel-selector--sidebar .release-channel-option__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.release-channel-selector--sidebar .release-channel-option__copy strong {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.release-channel-selector--sidebar .release-channel-option__copy small {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup>
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
|
||||
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
|
||||
import {
|
||||
clearSelectedReleaseChannel,
|
||||
releaseChannelSelectorVisible,
|
||||
switchSelectedReleaseChannel,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
import {
|
||||
isReleaseSourceOverrideAvailable,
|
||||
RELEASE_SOURCE_MODES,
|
||||
setReleaseSourceOverride,
|
||||
} from "@/services/releaseBootstrap.js";
|
||||
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
|
||||
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
|
||||
|
||||
const switchingSlug = ref("");
|
||||
const switchError = ref("");
|
||||
const { t, te } = useI18n({ useScope: "global" });
|
||||
const reloadWindow = inject("releaseSourceReload", () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
const tr = (key, fallback) => {
|
||||
const path = `configuration.release_manager.channel_selector.${key}`;
|
||||
return te(path) ? t(path) : fallback;
|
||||
};
|
||||
|
||||
const showUseLocalFrontend = computed(
|
||||
() =>
|
||||
releaseChannelSelectorVisible.value &&
|
||||
isReleaseSourceOverrideAvailable() &&
|
||||
String(releaseRuntimeState.source || "").toLowerCase() !== RELEASE_SOURCE_MODES.LOCAL
|
||||
);
|
||||
|
||||
const switchChannel = async (option) => {
|
||||
if (switchingSlug.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
switchingSlug.value = option.channelSlug || option.channel?.slug || "";
|
||||
switchError.value = "";
|
||||
try {
|
||||
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
|
||||
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
|
||||
} catch (error) {
|
||||
switchError.value = tr("switch_error", "Release channel could not be switched. The previous channel is still active.");
|
||||
} finally {
|
||||
switchingSlug.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const useLocalFrontend = () => {
|
||||
if (switchingSlug.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL);
|
||||
clearSelectedReleaseChannel();
|
||||
reloadWindow();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="release-channel-sidebar-selector">
|
||||
<ReleaseChannelSelector
|
||||
v-if="releaseChannelSelectorVisible"
|
||||
variant="sidebar"
|
||||
:switching-slug="switchingSlug"
|
||||
:disabled="Boolean(switchingSlug)"
|
||||
@select="switchChannel"
|
||||
/>
|
||||
<button
|
||||
v-if="showUseLocalFrontend"
|
||||
type="button"
|
||||
class="release-channel-sidebar-selector__local-button"
|
||||
data-testid="release-channel-use-local-frontend"
|
||||
:disabled="Boolean(switchingSlug)"
|
||||
@click="useLocalFrontend"
|
||||
>
|
||||
<i class="fas fa-code" aria-hidden="true"></i>
|
||||
<span>{{ tr("use_local_frontend", "Use local frontend") }}</span>
|
||||
</button>
|
||||
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
|
||||
{{ switchError }}
|
||||
</p>
|
||||
<ReleaseUpdateWidget />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-channel-sidebar-selector__local-button {
|
||||
width: calc(100% - 32px);
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: -2px 16px 14px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #b8c7d9;
|
||||
border-radius: 5px;
|
||||
background: #f8fbff;
|
||||
color: #153554;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.release-channel-sidebar-selector__local-button:hover:not(:disabled) {
|
||||
border-color: #6f8fb4;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.release-channel-sidebar-selector__local-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.release-channel-sidebar-selector__error {
|
||||
margin: 0 16px 14px;
|
||||
color: #b42318;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
@@ -3,17 +3,25 @@ import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
acknowledgeReleaseChannelSwitch,
|
||||
redirectToConfiguredReleaseFrontend,
|
||||
releaseChannelSwitchedStatus,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
const { t, te } = useI18n({ useScope: "global" });
|
||||
const tr = (key, params = {}) => t(`configuration.release_manager.channel_switched.${key}`, params);
|
||||
const status = computed(() => releaseChannelSwitchedStatus.value);
|
||||
const frontendLabel = computed(() => status.value.frontendBaseUrl || tr("current_app_image"));
|
||||
const apiLabel = computed(() => status.value.apiBaseUrl || tr("current_api"));
|
||||
const localizedChannelName = computed(() => {
|
||||
const key = `configuration.release_manager.channel_names.${status.value.channelSlug}`;
|
||||
return status.value.channelSlug && te(key) ? t(key) : status.value.channelName;
|
||||
});
|
||||
const localizedChannelDescription = computed(() => {
|
||||
const key = `configuration.release_manager.channel_descriptions.${status.value.channelSlug}`;
|
||||
return status.value.channelSlug && te(key) ? t(key) : status.value.description;
|
||||
});
|
||||
const frontendVersion = computed(() => status.value.versions?.frontend || null);
|
||||
const apiVersion = computed(() => status.value.versions?.api || null);
|
||||
const bundleLabel = computed(() =>
|
||||
status.value.versions?.bundle_id ? `#${status.value.versions.bundle_id}` : tr("assigned_channel")
|
||||
);
|
||||
|
||||
const versionLabel = (version, fallback) => {
|
||||
return version?.version_label || version?.commit_sha || fallback;
|
||||
@@ -21,7 +29,6 @@ const versionLabel = (version, fallback) => {
|
||||
|
||||
const continueToApp = () => {
|
||||
acknowledgeReleaseChannelSwitch(status.value.channel, status.value.principalKey);
|
||||
redirectToConfiguredReleaseFrontend();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -42,25 +49,33 @@ const continueToApp = () => {
|
||||
</div>
|
||||
<div class="release-card release-card--channel">
|
||||
<i class="fas fa-code-branch"></i>
|
||||
<span>{{ status.channelSlug || "channel" }}</span>
|
||||
<span>{{ localizedChannelName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="release-switch-copy">
|
||||
<p class="release-kicker">{{ tr("kicker") }}</p>
|
||||
<h1>{{ tr("title", { channel: status.channelName }) }}</h1>
|
||||
<h1>{{ tr("title", { channel: localizedChannelName }) }}</h1>
|
||||
<p class="release-summary">
|
||||
{{ tr("summary_prefix") }} <strong>{{ status.channelName }}</strong> {{ tr("summary_suffix") }}
|
||||
{{ tr("summary_prefix") }} <strong>{{ localizedChannelName }}</strong> {{ tr("summary_suffix") }}
|
||||
</p>
|
||||
<p v-if="status.description" class="release-description">
|
||||
{{ status.description }}
|
||||
<p v-if="localizedChannelDescription" class="release-description">
|
||||
{{ localizedChannelDescription }}
|
||||
</p>
|
||||
|
||||
<div class="release-details" data-testid="release-channel-switched-details">
|
||||
<span><strong>{{ tr("frontend") }}</strong>{{ frontendLabel }}</span>
|
||||
<span><strong>{{ tr("api") }}</strong>{{ apiLabel }}</span>
|
||||
<span><strong>{{ tr("frontend_version") }}</strong>{{ versionLabel(frontendVersion, tr("assigned_channel")) }}</span>
|
||||
<span><strong>{{ tr("api_version") }}</strong>{{ versionLabel(apiVersion, tr("assigned_channel")) }}</span>
|
||||
<span
|
||||
><strong>{{ tr("bundle") }}</strong
|
||||
>{{ bundleLabel }}</span
|
||||
>
|
||||
<span
|
||||
><strong>{{ tr("frontend_version") }}</strong
|
||||
>{{ versionLabel(frontendVersion, tr("assigned_channel")) }}</span
|
||||
>
|
||||
<span
|
||||
><strong>{{ tr("api_version") }}</strong
|
||||
>{{ versionLabel(apiVersion, tr("assigned_channel")) }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="release-actions">
|
||||
|
||||
@@ -3,28 +3,46 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
|
||||
import {
|
||||
ignoreUnavailableReleaseChannel,
|
||||
redirectToConfiguredReleaseFrontend,
|
||||
releaseChannelSelectorVisible,
|
||||
releaseChannelUnavailableStatus,
|
||||
RELEASE_CHANNEL_CHECK_INTERVAL_MS,
|
||||
switchSelectedReleaseChannel,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
const { t, te } = useI18n({ useScope: "global" });
|
||||
const tr = (key, params = {}) => t(`configuration.release_manager.channel_unavailable.${key}`, params);
|
||||
const checking = ref(false);
|
||||
const checkError = ref("");
|
||||
const secondsUntilNextCheck = ref(RELEASE_CHANNEL_CHECK_INTERVAL_MS / 1000);
|
||||
const switchingSlug = ref("");
|
||||
|
||||
let pollTimer = null;
|
||||
let countdownTimer = null;
|
||||
|
||||
const status = computed(() => releaseChannelUnavailableStatus.value);
|
||||
const localizedChannelName = computed(() => {
|
||||
const key = `configuration.release_manager.channel_names.${status.value.channelSlug}`;
|
||||
return status.value.channelSlug && te(key) ? t(key) : status.value.channelName;
|
||||
});
|
||||
const localizedChannelDescription = computed(() => {
|
||||
const key = `configuration.release_manager.channel_descriptions.${status.value.channelSlug}`;
|
||||
return status.value.channelSlug && te(key) ? t(key) : status.value.description;
|
||||
});
|
||||
const missingLabels = computed(() =>
|
||||
status.value.missing.map((key) =>
|
||||
key === "frontend_base_url" ? tr("frontend_url") : key === "api_base_url" ? tr("api_url") : key
|
||||
)
|
||||
status.value.missing.map((key) => {
|
||||
if (key === "release_bundle") return tr("release_bundle");
|
||||
if (key === "frontend_version") return tr("frontend_version");
|
||||
if (key === "api_version") return tr("api_version");
|
||||
if (key === "frontend_base_url") return tr("frontend_base_url");
|
||||
if (key === "api_base_url") return tr("api_base_url");
|
||||
if (key === "frontend_entry") return tr("frontend_entry");
|
||||
if (key === "release_runtime") return tr("release_runtime");
|
||||
return key;
|
||||
})
|
||||
);
|
||||
|
||||
const resetCountdown = () => {
|
||||
@@ -40,7 +58,6 @@ const checkAgain = async () => {
|
||||
checkError.value = "";
|
||||
try {
|
||||
await SessionUser.refreshReleaseRuntime();
|
||||
redirectToConfiguredReleaseFrontend();
|
||||
} catch (error) {
|
||||
checkError.value = tr("refresh_error");
|
||||
} finally {
|
||||
@@ -53,6 +70,23 @@ const ignoreForFiveMinutes = () => {
|
||||
ignoreUnavailableReleaseChannel(status.value.channel);
|
||||
};
|
||||
|
||||
const switchChannel = async (option) => {
|
||||
if (switchingSlug.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
switchingSlug.value = option.channelSlug || option.channel?.slug || "";
|
||||
checkError.value = "";
|
||||
try {
|
||||
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
|
||||
} catch (error) {
|
||||
checkError.value = tr("refresh_error");
|
||||
} finally {
|
||||
switchingSlug.value = "";
|
||||
resetCountdown();
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await SessionUser.auth.logout();
|
||||
router.push({ name: "login" });
|
||||
@@ -91,7 +125,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="release-card release-card--channel">
|
||||
<i class="fas fa-code-branch"></i>
|
||||
<span>{{ status.channelSlug || "channel" }}</span>
|
||||
<span>{{ localizedChannelName }}</span>
|
||||
</div>
|
||||
<div class="release-card release-card--base">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
@@ -103,15 +137,20 @@ onBeforeUnmount(() => {
|
||||
<p class="release-kicker">{{ tr("kicker") }}</p>
|
||||
<h1>{{ tr("title") }}</h1>
|
||||
<p class="release-summary">
|
||||
{{ tr("summary_prefix") }} <strong>{{ status.channelName }}</strong
|
||||
{{ tr("summary_prefix") }} <strong>{{ localizedChannelName }}</strong
|
||||
>{{ tr("summary_suffix") }}
|
||||
</p>
|
||||
<p v-if="status.description" class="release-description">
|
||||
{{ status.description }}
|
||||
<p v-if="localizedChannelDescription" class="release-description">
|
||||
{{ localizedChannelDescription }}
|
||||
</p>
|
||||
<div class="release-missing" data-testid="release-channel-missing">
|
||||
<span v-for="label in missingLabels" :key="label">{{ label }}</span>
|
||||
</div>
|
||||
<ReleaseChannelSelector
|
||||
v-if="releaseChannelSelectorVisible"
|
||||
:switching-slug="switchingSlug"
|
||||
@select="switchChannel"
|
||||
/>
|
||||
<p class="release-check-status" data-testid="release-channel-next-check">
|
||||
{{ tr("checking_again", { seconds: secondsUntilNextCheck }) }}
|
||||
</p>
|
||||
@@ -122,9 +161,9 @@ onBeforeUnmount(() => {
|
||||
type="is-dark"
|
||||
icon-left="clock"
|
||||
icon-pack="fas"
|
||||
data-testid="release-channel-ignore"
|
||||
@click="ignoreForFiveMinutes"
|
||||
>
|
||||
data-testid="release-channel-ignore"
|
||||
@click="ignoreForFiveMinutes"
|
||||
>
|
||||
{{ tr("ignore") }}
|
||||
</b-button>
|
||||
<b-button
|
||||
@@ -132,9 +171,9 @@ onBeforeUnmount(() => {
|
||||
icon-left="sync-alt"
|
||||
icon-pack="fas"
|
||||
:loading="checking"
|
||||
data-testid="release-channel-check-again"
|
||||
@click="checkAgain"
|
||||
>
|
||||
data-testid="release-channel-check-again"
|
||||
@click="checkAgain"
|
||||
>
|
||||
{{ tr("check_again") }}
|
||||
</b-button>
|
||||
<b-button type="is-danger is-light" icon-left="sign-out-alt" icon-pack="fas" @click="logout">
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import ReleaseEntityLink from "@/components/release/ReleaseEntityLink.vue";
|
||||
|
||||
const props = defineProps({
|
||||
channels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedChannelSlug: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
selectedApp: {
|
||||
type: String,
|
||||
default: "all",
|
||||
},
|
||||
selectedBranch: {
|
||||
type: String,
|
||||
default: "master",
|
||||
},
|
||||
branchOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
branchStatus: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
runtimeSource: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
runtimeApiBaseUrl: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
runtimeFrontendBaseUrl: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
busy: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
refreshing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: "Loading release context...",
|
||||
},
|
||||
canDeploy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:channel", "update:app", "update:branch", "test", "sync", "refresh", "open-entity"]);
|
||||
|
||||
const routeSlugForChannel = (channel) => {
|
||||
const slug = String(channel?.slug || channel?.channel_slug || "").trim().toLowerCase();
|
||||
return slug === "stable" ? "master" : slug;
|
||||
};
|
||||
|
||||
const selectedChannel = computed(
|
||||
() =>
|
||||
props.channels.find((channel) => String(channel.slug || channel.channel_slug) === props.selectedChannelSlug) ||
|
||||
props.channels.find((channel) => channel.default_channel) ||
|
||||
props.channels[0] ||
|
||||
null
|
||||
);
|
||||
|
||||
const selectedRouteSlug = computed(() => routeSlugForChannel(selectedChannel.value) || props.selectedBranch || "master");
|
||||
|
||||
const selectedEndpointApps = computed(() => {
|
||||
if (props.selectedApp === "frontend") return ["frontend"];
|
||||
if (props.selectedApp === "api") return ["api"];
|
||||
return ["frontend", "api"];
|
||||
});
|
||||
|
||||
const isLocalRuntime = computed(() => String(props.runtimeSource || "").toLowerCase() === "local");
|
||||
const releaseSourceLabel = computed(() => (isLocalRuntime.value ? "Local source" : "Deployment source"));
|
||||
const runtimeFrontendBaseUrl = computed(() => {
|
||||
const value = String(props.runtimeFrontendBaseUrl || "").replace(/\/+$/, "");
|
||||
if (value) return value;
|
||||
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
|
||||
return "/";
|
||||
});
|
||||
const runtimeApiBaseUrl = computed(() => String(props.runtimeApiBaseUrl || "/api").replace(/\/+$/, "") || "/api");
|
||||
const endpointForApp = (app) =>
|
||||
isLocalRuntime.value
|
||||
? app === "api"
|
||||
? runtimeApiBaseUrl.value
|
||||
: runtimeFrontendBaseUrl.value
|
||||
: `https://api-v2.truckwash.io/${selectedRouteSlug.value}/${app}`;
|
||||
const endpointLabelForApp = (app) =>
|
||||
isLocalRuntime.value
|
||||
? app === "api"
|
||||
? runtimeApiBaseUrl.value
|
||||
: "local frontend"
|
||||
: `/${selectedRouteSlug.value}/${app}`;
|
||||
|
||||
const appBranchStatus = computed(() => {
|
||||
if (props.selectedApp === "frontend" || props.selectedApp === "api") {
|
||||
return props.branchStatus?.[props.selectedApp] || {};
|
||||
}
|
||||
const values = [props.branchStatus?.frontend, props.branchStatus?.api].filter(Boolean);
|
||||
const missing = values.find((status) => ["missing", "not_configured", "unknown"].includes(String(status.state || "").toLowerCase()));
|
||||
return missing || values[0] || {};
|
||||
});
|
||||
|
||||
const branchState = computed(() => String(appBranchStatus.value?.state || "unknown").toLowerCase());
|
||||
const branchWarning = computed(() =>
|
||||
["missing", "not_configured", "unknown", "stale", "failed"].includes(branchState.value)
|
||||
? `${props.selectedBranch || "unknown"} is ${branchState.value || "unknown"} for the selected context.`
|
||||
: ""
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="release-context-bar"
|
||||
data-testid="release-context-bar"
|
||||
aria-label="Release context"
|
||||
:aria-busy="loading || refreshing"
|
||||
>
|
||||
<div v-if="loading" class="release-context-bar__loading" data-testid="release-context-loading" role="status" aria-live="polite">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span>{{ loadingText }}</span>
|
||||
</div>
|
||||
<div class="release-context-bar__controls">
|
||||
<label>
|
||||
<span>Channel</span>
|
||||
<select
|
||||
class="select is-small"
|
||||
:value="selectedChannelSlug"
|
||||
data-testid="release-context-channel"
|
||||
:disabled="loading"
|
||||
@change="emit('update:channel', $event.target.value)"
|
||||
>
|
||||
<option v-for="channel in channels" :key="channel.id || channel.slug" :value="channel.slug">
|
||||
{{ channel.name || channel.slug }} / {{ routeSlugForChannel(channel) || "unknown" }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>App</span>
|
||||
<select
|
||||
class="select is-small"
|
||||
:value="selectedApp"
|
||||
data-testid="release-context-app"
|
||||
:disabled="loading"
|
||||
@change="emit('update:app', $event.target.value)"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="frontend">Frontend</option>
|
||||
<option value="api">API</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="release-context-bar__branch">
|
||||
<span>Branch</span>
|
||||
<input
|
||||
class="input is-small"
|
||||
list="release-context-branches"
|
||||
:value="selectedBranch"
|
||||
data-testid="release-context-branch"
|
||||
:disabled="loading"
|
||||
@change="emit('update:branch', $event.target.value)"
|
||||
@keydown.enter.prevent="emit('update:branch', $event.target.value)"
|
||||
/>
|
||||
<datalist id="release-context-branches">
|
||||
<option v-for="branch in branchOptions" :key="branch" :value="branch" />
|
||||
</datalist>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="release-context-bar__status">
|
||||
<b-tag
|
||||
:class="isLocalRuntime ? 'is-info' : 'is-success'"
|
||||
data-testid="release-context-source"
|
||||
>
|
||||
{{ releaseSourceLabel }}
|
||||
</b-tag>
|
||||
<b-tag :class="branchState === 'available' || branchState === 'ready' ? 'is-success' : 'is-warning'">
|
||||
{{ branchState }}
|
||||
</b-tag>
|
||||
<span v-if="branchWarning" class="release-context-bar__warning" data-testid="release-context-branch-warning">
|
||||
{{ branchWarning }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="release-context-bar__endpoints" data-testid="release-context-endpoints">
|
||||
<ReleaseEntityLink
|
||||
v-for="app in selectedEndpointApps"
|
||||
:key="app"
|
||||
type="endpoint"
|
||||
:label="endpointLabelForApp(app)"
|
||||
:entity="{ url: endpointForApp(app), route_prefix: endpointLabelForApp(app), app, channel: selectedChannelSlug }"
|
||||
@open="(payload) => emit('open-entity', payload)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="release-context-bar__actions">
|
||||
<b-button
|
||||
size="is-small"
|
||||
icon-left="vial"
|
||||
icon-pack="fas"
|
||||
:disabled="loading || !canDeploy"
|
||||
:loading="busy === 'release:test'"
|
||||
data-testid="release-context-test"
|
||||
@click="emit('test')"
|
||||
>
|
||||
Test
|
||||
</b-button>
|
||||
<b-button
|
||||
size="is-small"
|
||||
icon-left="rotate"
|
||||
icon-pack="fas"
|
||||
:disabled="loading || !canDeploy || !selectedChannel"
|
||||
:loading="busy === `channel:${selectedChannel?.id}:sync`"
|
||||
data-testid="release-context-sync"
|
||||
@click="emit('sync', selectedChannel)"
|
||||
>
|
||||
Sync
|
||||
</b-button>
|
||||
<b-button
|
||||
size="is-small"
|
||||
icon-left="refresh"
|
||||
icon-pack="fas"
|
||||
data-testid="release-context-refresh"
|
||||
:disabled="loading || refreshing"
|
||||
:loading="refreshing"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
Refresh
|
||||
</b-button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-context-bar {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #d8dee8;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(11rem, 0.6fr) minmax(0, 1fr) auto;
|
||||
min-height: 4rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.release-context-bar__loading {
|
||||
align-items: center;
|
||||
color: #52627a;
|
||||
display: inline-flex;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
gap: 0.45rem;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.release-context-bar__controls,
|
||||
.release-context-bar__status,
|
||||
.release-context-bar__endpoints,
|
||||
.release-context-bar__actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.release-context-bar label {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.release-context-bar label > span {
|
||||
color: #667085;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-context-bar__branch {
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.release-context-bar__warning {
|
||||
color: #9f1f17;
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.release-context-bar__endpoints {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.release-context-bar__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.release-context-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.release-context-bar__controls,
|
||||
.release-context-bar__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: "No records",
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: "Loading release data...",
|
||||
},
|
||||
loadingColspan: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="release-data-grid" data-testid="release-data-grid" :aria-busy="loading">
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<slot name="head" />
|
||||
<tbody v-if="loading" data-testid="release-data-grid-loading">
|
||||
<tr>
|
||||
<td :colspan="loadingColspan">
|
||||
<div class="release-data-grid__loading" role="status" aria-live="polite">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span>{{ loadingText }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<slot v-else />
|
||||
<tbody v-if="!loading && $slots.empty">
|
||||
<slot name="empty" />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-data-grid {
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 8px;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.release-data-grid :deep(table) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.release-data-grid :deep(thead th) {
|
||||
background: #f8fafc;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.release-data-grid__loading {
|
||||
align-items: center;
|
||||
color: #52627a;
|
||||
display: flex;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
min-height: 5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
releaseEntityDisplayLabel,
|
||||
releaseEntityFacts,
|
||||
releaseEntityTooltipText,
|
||||
} from "@/components/release/releaseEntityFacts.js";
|
||||
|
||||
const emit = defineEmits(["open"]);
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: "entity",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
entity: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
detail: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
facts: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const displayLabel = computed(() => releaseEntityDisplayLabel(props.label, props.entity));
|
||||
const tooltipFacts = computed(() =>
|
||||
Array.isArray(props.facts) && props.facts.length > 0
|
||||
? props.facts
|
||||
: releaseEntityFacts({ type: props.type, entity: props.entity || {}, detail: props.detail })
|
||||
);
|
||||
const tooltip = computed(() => releaseEntityTooltipText(tooltipFacts.value));
|
||||
|
||||
const open = () => {
|
||||
emit("open", {
|
||||
type: props.type,
|
||||
label: displayLabel.value,
|
||||
detail: props.detail,
|
||||
entity: props.entity || {},
|
||||
facts: tooltipFacts.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<b-tooltip :label="tooltip || displayLabel" multilined type="is-dark">
|
||||
<template #content>
|
||||
<div class="release-entity-tooltip" data-testid="release-entity-tooltip">
|
||||
<strong>{{ displayLabel }}</strong>
|
||||
<dl>
|
||||
<template v-for="fact in tooltipFacts" :key="`${fact.label}-${fact.value}`">
|
||||
<dt>{{ fact.label }}</dt>
|
||||
<dd>{{ fact.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
class="release-entity-link"
|
||||
type="button"
|
||||
:title="tooltip || displayLabel"
|
||||
:aria-label="tooltip || displayLabel"
|
||||
:data-entity-type="type"
|
||||
@click="open"
|
||||
>
|
||||
<span>{{ displayLabel }}</span>
|
||||
<i class="fas fa-circle-info" aria-hidden="true"></i>
|
||||
</button>
|
||||
</b-tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-entity-link {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #0747a6;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
gap: 0.35rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.release-entity-link span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.release-entity-link i {
|
||||
color: #52627a;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.release-entity-tooltip {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
max-width: min(28rem, 82vw);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.release-entity-tooltip dl {
|
||||
display: grid;
|
||||
gap: 0.25rem 0.75rem;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.release-entity-tooltip dt {
|
||||
color: #cbd5e1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.release-entity-tooltip dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
isSameReleaseCommit,
|
||||
releaseUpdateState,
|
||||
shortReleaseCommit,
|
||||
} from "@/services/releaseUpdate.js";
|
||||
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
|
||||
|
||||
const { locale } = useI18n({ useScope: "global" });
|
||||
const textValue = (value) => String(value ?? "").trim();
|
||||
const githubAccess = (version) =>
|
||||
version?.metadata?.github_access && typeof version.metadata.github_access === "object"
|
||||
? version.metadata.github_access
|
||||
: {};
|
||||
const githubCommit = (version) => {
|
||||
const access = githubAccess(version);
|
||||
return version?.commit && typeof version.commit === "object"
|
||||
? version.commit
|
||||
: access.commit || access.latest_commit || null;
|
||||
};
|
||||
const versionTimestamp = (version) => {
|
||||
const commit = githubCommit(version);
|
||||
return (
|
||||
textValue(version?.deployed_at) ||
|
||||
textValue(version?.released_at) ||
|
||||
textValue(version?.promoted_at) ||
|
||||
textValue(version?.created_at) ||
|
||||
textValue(version?.commit_authored_at) ||
|
||||
textValue(commit?.authored_at) ||
|
||||
textValue(githubAccess(version)?.commit_authored_at)
|
||||
);
|
||||
};
|
||||
const formatReleaseTime = (value) => {
|
||||
const timestamp = textValue(value);
|
||||
if (!timestamp) {
|
||||
return "";
|
||||
}
|
||||
const parsed = new Date(timestamp);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return timestamp;
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale.value || undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(parsed);
|
||||
};
|
||||
|
||||
const currentText = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
|
||||
const latestText = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit));
|
||||
const currentReleaseTime = computed(() =>
|
||||
formatReleaseTime(versionTimestamp(releaseRuntimeState.versions?.frontend || null))
|
||||
);
|
||||
const hasLatestUpdate = computed(
|
||||
() =>
|
||||
Boolean(releaseUpdateState.latestCommit) &&
|
||||
!isSameReleaseCommit(releaseUpdateState.currentCommit, releaseUpdateState.latestCommit)
|
||||
);
|
||||
const title = computed(() => {
|
||||
const releaseTime = currentReleaseTime.value;
|
||||
if (!hasLatestUpdate.value) {
|
||||
return releaseTime ? `Frontend ${currentText.value} released ${releaseTime}` : `Frontend ${currentText.value}`;
|
||||
}
|
||||
return releaseTime
|
||||
? `Frontend ${currentText.value} released ${releaseTime} to ${latestText.value}`
|
||||
: `Frontend ${currentText.value} to ${latestText.value}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="release-frontend-version-badge"
|
||||
:class="{ 'release-frontend-version-badge--update': hasLatestUpdate }"
|
||||
:title="title"
|
||||
data-testid="release-frontend-version-badge"
|
||||
>
|
||||
<span class="release-frontend-version-badge__commit">{{ currentText }}</span>
|
||||
<template v-if="hasLatestUpdate">
|
||||
<span class="release-frontend-version-badge__arrow">-></span>
|
||||
<span class="release-frontend-version-badge__commit">{{ latestText }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-frontend-version-badge {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.release-frontend-version-badge__commit {
|
||||
padding: 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.release-frontend-version-badge--update {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.release-frontend-version-badge__arrow {
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup>
|
||||
import ReleaseContextBar from "@/components/release/ReleaseContextBar.vue";
|
||||
import ReleaseRouteTabs from "@/components/release/ReleaseRouteTabs.vue";
|
||||
|
||||
defineProps({
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activePanel: {
|
||||
type: String,
|
||||
default: "overview",
|
||||
},
|
||||
channels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedChannelSlug: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
selectedApp: {
|
||||
type: String,
|
||||
default: "all",
|
||||
},
|
||||
selectedBranch: {
|
||||
type: String,
|
||||
default: "master",
|
||||
},
|
||||
branchOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
branchStatus: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
runtimeSource: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
runtimeApiBaseUrl: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
runtimeFrontendBaseUrl: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
busy: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
refreshing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: "Loading Release Manager...",
|
||||
},
|
||||
refreshingText: {
|
||||
type: String,
|
||||
default: "Refreshing release data...",
|
||||
},
|
||||
canDeploy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"navigate",
|
||||
"update:channel",
|
||||
"update:app",
|
||||
"update:branch",
|
||||
"test",
|
||||
"sync",
|
||||
"refresh",
|
||||
"open-entity",
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="release-manager-workspace" data-testid="release-manager-workspace" :aria-busy="loading || refreshing">
|
||||
<ReleaseContextBar
|
||||
:channels="channels"
|
||||
:selected-channel-slug="selectedChannelSlug"
|
||||
:selected-app="selectedApp"
|
||||
:selected-branch="selectedBranch"
|
||||
:branch-options="branchOptions"
|
||||
:branch-status="branchStatus"
|
||||
:runtime-source="runtimeSource"
|
||||
:runtime-api-base-url="runtimeApiBaseUrl"
|
||||
:runtime-frontend-base-url="runtimeFrontendBaseUrl"
|
||||
:busy="busy"
|
||||
:loading="loading"
|
||||
:refreshing="refreshing"
|
||||
:loading-text="loadingText"
|
||||
:can-deploy="canDeploy"
|
||||
@update:channel="(value) => emit('update:channel', value)"
|
||||
@update:app="(value) => emit('update:app', value)"
|
||||
@update:branch="(value) => emit('update:branch', value)"
|
||||
@test="emit('test')"
|
||||
@sync="(channel) => emit('sync', channel)"
|
||||
@refresh="emit('refresh')"
|
||||
@open-entity="(payload) => emit('open-entity', payload)"
|
||||
/>
|
||||
<ReleaseRouteTabs :tabs="tabs" :active-key="activePanel" @navigate="(panel) => emit('navigate', panel)" />
|
||||
<div
|
||||
class="release-manager-workspace__refreshing"
|
||||
:class="{ 'release-manager-workspace__refreshing--active': refreshing && !loading }"
|
||||
data-testid="release-manager-refreshing"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
:aria-hidden="!(refreshing && !loading)"
|
||||
>
|
||||
<template v-if="refreshing && !loading">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span>{{ refreshingText }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<main class="release-manager-workspace__panel" :data-active-panel="activePanel">
|
||||
<slot />
|
||||
</main>
|
||||
<slot name="overlays" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-manager-workspace {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr);
|
||||
height: calc(100vh - 11rem);
|
||||
min-height: 42rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.release-manager-workspace__refreshing {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border-bottom: 0 solid transparent;
|
||||
color: #17416f;
|
||||
display: flex;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
gap: 0.45rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.release-manager-workspace__refreshing--active {
|
||||
background: #eef6ff;
|
||||
border-bottom-color: #cfe3ff;
|
||||
border-bottom-width: 1px;
|
||||
min-height: 2.35rem;
|
||||
padding-bottom: 0.45rem;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
|
||||
.release-manager-workspace__panel {
|
||||
background: #eef3f8;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.release-manager-workspace {
|
||||
display: block;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.release-manager-workspace__panel {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import ReleaseOperationTimeline from "@/components/release/ReleaseOperationTimeline.vue";
|
||||
|
||||
const emit = defineEmits(["close", "retry"]);
|
||||
|
||||
defineProps({
|
||||
operation: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="operation" class="release-operation-modal" role="dialog" aria-modal="true" data-testid="release-operation-modal">
|
||||
<div class="release-operation-modal__backdrop" @click="emit('close')"></div>
|
||||
<section class="release-operation-modal__panel">
|
||||
<header>
|
||||
<div>
|
||||
<span class="release-operation-modal__eyebrow">{{ operation.operation_type || "operation" }}</span>
|
||||
<h3>{{ operation.title || `Operation #${operation.id}` }}</h3>
|
||||
<p>{{ operation.summary || "Operation is running." }}</p>
|
||||
</div>
|
||||
<button type="button" class="delete" aria-label="Close" @click="emit('close')"></button>
|
||||
</header>
|
||||
|
||||
<ReleaseOperationTimeline :operation="operation" @retry="(step) => emit('retry', step)" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-operation-modal {
|
||||
inset: 0;
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.release-operation-modal__backdrop {
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.release-operation-modal__panel {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 22px 70px rgba(15, 23, 42, 0.28);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-height: min(86vh, 780px);
|
||||
max-width: min(920px, calc(100vw - 2rem));
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
width: 56rem;
|
||||
}
|
||||
|
||||
.release-operation-modal__panel header {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.release-operation-modal__panel h3,
|
||||
.release-operation-modal__panel p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.release-operation-modal__eyebrow {
|
||||
color: #667085;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.release-operation-modal__panel {
|
||||
inset: 0.5rem;
|
||||
max-height: calc(100vh - 1rem);
|
||||
max-width: none;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const emit = defineEmits(["retry"]);
|
||||
|
||||
const props = defineProps({
|
||||
operation: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const statusClass = (status) => {
|
||||
const normalized = String(status || "").toLowerCase();
|
||||
if (["passed", "deployed", "completed", "success"].includes(normalized)) return "is-success";
|
||||
if (["failed", "error"].includes(normalized)) return "is-danger";
|
||||
if (["warning", "skipped"].includes(normalized)) return "is-warning";
|
||||
if (["running", "queued", "pending"].includes(normalized)) return "is-info";
|
||||
return "is-light";
|
||||
};
|
||||
|
||||
const shortCommit = (value) => {
|
||||
const text = String(value || "").trim();
|
||||
return text.length > 12 ? text.slice(0, 12) : text;
|
||||
};
|
||||
|
||||
const operationContext = computed(() => props.operation?.context || {});
|
||||
const releaseGateContext = computed(() => operationContext.value.release_gate || {});
|
||||
const autoSyncEvent = computed(
|
||||
() => operationContext.value.auto_sync_event || props.operation?.steps?.find((step) => step.context?.auto_sync_event)?.context?.auto_sync_event || null
|
||||
);
|
||||
const operationMeta = computed(() => {
|
||||
const context = operationContext.value;
|
||||
const gate = releaseGateContext.value;
|
||||
const event = autoSyncEvent.value || {};
|
||||
const rows = [
|
||||
["Source", context.source || event.source || (gate.auto_sync ? "release_gate" : "")],
|
||||
["App", props.operation?.app || context.app || gate.app || event.app],
|
||||
["Commit", shortCommit(context.commit_sha || gate.expected_commit || event.commit_sha)],
|
||||
["Auto-sync", event.status || (gate.auto_sync ? "requested" : "")],
|
||||
];
|
||||
|
||||
return rows
|
||||
.filter(([, value]) => String(value || "").trim() !== "")
|
||||
.map(([label, value]) => ({ label, value }));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="release-operation-timeline" data-testid="release-operation-timeline">
|
||||
<div class="release-operation-timeline__status">
|
||||
<b-tag :class="statusClass(operation?.status)">{{ operation?.status || "unknown" }}</b-tag>
|
||||
<span>{{ operation?.passed_step_count || 0 }} passed</span>
|
||||
<span>{{ operation?.failed_step_count || 0 }} failed</span>
|
||||
<span>{{ operation?.warning_step_count || 0 }} warnings</span>
|
||||
</div>
|
||||
|
||||
<div v-if="operationMeta.length" class="release-operation-timeline__meta" data-testid="release-operation-meta">
|
||||
<span v-for="item in operationMeta" :key="item.label">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span>{{ item.value }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ol class="release-operation-timeline__steps">
|
||||
<li
|
||||
v-for="step in operation?.steps || []"
|
||||
:key="step.id || step.step_key"
|
||||
:class="`is-${step.status || 'unknown'}`"
|
||||
data-testid="release-operation-step"
|
||||
>
|
||||
<div class="release-operation-timeline__step-head">
|
||||
<strong>{{ step.label || step.step_key }}</strong>
|
||||
<b-tag :class="statusClass(step.status)" size="is-small">{{ step.status || "unknown" }}</b-tag>
|
||||
</div>
|
||||
<p v-if="step.message">{{ step.message }}</p>
|
||||
<div v-if="step.diagnostic" class="release-operation-timeline__diagnostic">
|
||||
<strong>Diagnostic</strong>
|
||||
<span>{{ step.diagnostic }}</span>
|
||||
</div>
|
||||
<div v-if="step.solution_hint" class="release-operation-timeline__solution">
|
||||
<strong>Solution</strong>
|
||||
<span>{{ step.solution_hint }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="step.context?.retry_action"
|
||||
class="button is-small"
|
||||
type="button"
|
||||
@click="emit('retry', step)"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<footer v-if="operation?.solution_hint">
|
||||
<strong>Next</strong>
|
||||
<span>{{ operation.solution_hint }}</span>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-operation-timeline {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.release-operation-timeline__status,
|
||||
.release-operation-timeline__meta,
|
||||
.release-operation-timeline__step-head,
|
||||
.release-operation-timeline__diagnostic,
|
||||
.release-operation-timeline__solution,
|
||||
.release-operation-timeline footer {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.release-operation-timeline__status {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.release-operation-timeline__meta {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.release-operation-timeline__meta span {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.release-operation-timeline__steps {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.release-operation-timeline__steps li {
|
||||
border: 1px solid #d8dee8;
|
||||
border-left: 4px solid #94a3b8;
|
||||
border-radius: 7px;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.release-operation-timeline__steps li.is-passed,
|
||||
.release-operation-timeline__steps li.is-deployed,
|
||||
.release-operation-timeline__steps li.is-completed,
|
||||
.release-operation-timeline__steps li.is-success {
|
||||
border-left-color: #1f9d5a;
|
||||
}
|
||||
|
||||
.release-operation-timeline__steps li.is-failed,
|
||||
.release-operation-timeline__steps li.is-error {
|
||||
border-left-color: #d92d20;
|
||||
}
|
||||
|
||||
.release-operation-timeline__steps li.is-warning,
|
||||
.release-operation-timeline__steps li.is-skipped {
|
||||
border-left-color: #d99a20;
|
||||
}
|
||||
|
||||
.release-operation-timeline__steps li.is-running,
|
||||
.release-operation-timeline__steps li.is-queued,
|
||||
.release-operation-timeline__steps li.is-pending {
|
||||
border-left-color: #1f6feb;
|
||||
}
|
||||
|
||||
.release-operation-timeline__diagnostic,
|
||||
.release-operation-timeline__solution,
|
||||
.release-operation-timeline footer {
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
justify-content: flex-start;
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,807 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { BAutocomplete } from "buefy";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
import {
|
||||
getReleaseTimelineSession,
|
||||
listReleaseTimelineSessions,
|
||||
searchReleaseTimeline,
|
||||
setReleaseReplayTarget,
|
||||
} from "@/services/superuserReleases.js";
|
||||
|
||||
const props = defineProps({
|
||||
channels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
moduleKeys: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canReplay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["refresh-summary"]);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
const tr = (key, params = {}) => t(`configuration.release_manager.${key}`, params);
|
||||
const trFallback = (key, fallback, params = {}) => {
|
||||
const fullKey = `configuration.release_manager.${key}`;
|
||||
const translated = t(fullKey, params);
|
||||
return translated === fullKey ? fallback : translated;
|
||||
};
|
||||
const valueLabel = (group, value) => trFallback(`values.${group}.${value}`, String(value || ""));
|
||||
|
||||
const replayForm = reactive({
|
||||
target_type: "user",
|
||||
target_id: "",
|
||||
channel_id: null,
|
||||
capture_level: "full_redacted",
|
||||
expires_at: "",
|
||||
});
|
||||
|
||||
const timelineFilters = reactive({
|
||||
trace_id: "",
|
||||
channel_slug: "",
|
||||
principal_type: "",
|
||||
principal_id: "",
|
||||
customer_number: "",
|
||||
module_key: "",
|
||||
severity: "",
|
||||
event_type: "",
|
||||
device_type: "",
|
||||
frontend_version: "",
|
||||
api_version: "",
|
||||
date_from: "",
|
||||
date_to: "",
|
||||
has_error_report: false,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const busy = ref("");
|
||||
const errors = ref([]);
|
||||
const timelineEvents = ref([]);
|
||||
const timelineSessions = ref([]);
|
||||
const selectedDetail = ref(null);
|
||||
const selectedDetailTab = ref("events");
|
||||
|
||||
const responseData = (response, fallback) => response?.data?.data ?? fallback;
|
||||
const parseError = (error) =>
|
||||
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || "Unknown error";
|
||||
|
||||
const channelOptions = computed(() => props.channels || []);
|
||||
const moduleOptions = computed(() => {
|
||||
const query = String(timelineFilters.module_key || "").toLowerCase();
|
||||
return (props.moduleKeys || [])
|
||||
.map((key) => ({
|
||||
value: key,
|
||||
title: key,
|
||||
description: trFallback("autocomplete.timeline_module_key", "Timeline module"),
|
||||
icon: "fas fa-puzzle-piece",
|
||||
}))
|
||||
.filter((option) => !query || option.value.toLowerCase().includes(query))
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
const sessionRows = computed(() => (Array.isArray(timelineSessions.value) ? timelineSessions.value : []));
|
||||
const selectedSession = computed(() => selectedDetail.value?.session || null);
|
||||
const detailEvents = computed(() =>
|
||||
Array.isArray(selectedDetail.value?.events) ? selectedDetail.value.events : []
|
||||
);
|
||||
const detailErrorReports = computed(() =>
|
||||
Array.isArray(selectedDetail.value?.error_reports) ? selectedDetail.value.error_reports : []
|
||||
);
|
||||
const selectedRelease = computed(() => selectedDetail.value?.release || selectedSession.value?.release || {});
|
||||
const timelineLoading = computed(() => busy.value === "timeline:search");
|
||||
const timelineRefreshing = computed(() => timelineLoading.value && (sessionRows.value.length > 0 || timelineEvents.value.length > 0));
|
||||
const sessionRowsLoading = computed(() => timelineLoading.value && sessionRows.value.length === 0);
|
||||
const timelineEventsLoading = computed(() => timelineLoading.value && timelineEvents.value.length === 0);
|
||||
|
||||
const filterPayload = () => {
|
||||
const payload = {};
|
||||
for (const [key, value] of Object.entries(timelineFilters)) {
|
||||
if (value === "" || value === null || value === false) {
|
||||
continue;
|
||||
}
|
||||
payload[key] = value;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const statusClass = (status) => ({
|
||||
"is-ok": ["ok", "active", "deployed", "promoted", "ready", "stable", "enabled", "info"].includes(status),
|
||||
"is-degraded": ["queued", "deploying", "draft", "metadata", "warning"].includes(status),
|
||||
"is-down": ["failed", "down", "error"].includes(status),
|
||||
});
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) {
|
||||
return "--";
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
};
|
||||
|
||||
const shortValue = (value) => {
|
||||
const text = String(value || "");
|
||||
return text.length > 16 ? `${text.slice(0, 12)}...` : text || "--";
|
||||
};
|
||||
|
||||
const userLabel = (session) => {
|
||||
const user = session?.user || {};
|
||||
return [user.label, user.customer_number ? `Customer ${user.customer_number}` : null].filter(Boolean).join(" / ") || "--";
|
||||
};
|
||||
|
||||
const releaseLabel = (session, app) => {
|
||||
const release = session?.release?.[app] || {};
|
||||
return release.version_label || release.commit_sha || "--";
|
||||
};
|
||||
|
||||
const releaseApp = (app) => selectedRelease.value?.[app] || selectedSession.value?.release?.[app] || {};
|
||||
|
||||
const versionReferenceLabel = (app) => {
|
||||
const version = releaseApp(app)?.version;
|
||||
return version?.id ? `#${version.id} ${version.status || ""}`.trim() : "--";
|
||||
};
|
||||
|
||||
const deploymentReferenceLabel = (app) => {
|
||||
const deployment = releaseApp(app)?.deployment;
|
||||
return deployment?.id ? `#${deployment.id} ${deployment.status || ""}`.trim() : "--";
|
||||
};
|
||||
|
||||
const bundleReferenceLabel = () => {
|
||||
const bundle = selectedRelease.value?.bundle;
|
||||
return bundle?.id ? `#${bundle.id} ${bundle.version_label || bundle.status || ""}`.trim() : "--";
|
||||
};
|
||||
|
||||
const deviceLabel = (session) => {
|
||||
const device = session?.device || {};
|
||||
return [device.type, device.browser_name, device.os_name].filter(Boolean).join(" / ") || "--";
|
||||
};
|
||||
|
||||
const viewportLabel = (session) => {
|
||||
const device = session?.device || {};
|
||||
if (!device.viewport_width || !device.viewport_height) {
|
||||
return "--";
|
||||
}
|
||||
return `${device.viewport_width}x${device.viewport_height} @ ${device.device_pixel_ratio || 1}`;
|
||||
};
|
||||
|
||||
const jsonPreview = (value) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "--";
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
};
|
||||
|
||||
async function run(key, callback) {
|
||||
busy.value = key;
|
||||
errors.value = [];
|
||||
try {
|
||||
await callback();
|
||||
} catch (error) {
|
||||
errors.value.push(error);
|
||||
} finally {
|
||||
busy.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function enableReplayTarget() {
|
||||
await run("replay:target", async () => {
|
||||
await setReleaseReplayTarget({ ...replayForm, enabled: true });
|
||||
Object.assign(replayForm, { target_id: "", expires_at: "" });
|
||||
emit("refresh-summary");
|
||||
await loadReplayData();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadReplayData() {
|
||||
await run("timeline:search", async () => {
|
||||
const filters = filterPayload();
|
||||
const [sessionsResponse, eventsResponse] = await Promise.all([
|
||||
listReleaseTimelineSessions(filters),
|
||||
searchReleaseTimeline(filters),
|
||||
]);
|
||||
timelineSessions.value = responseData(sessionsResponse, []);
|
||||
timelineEvents.value = responseData(eventsResponse, []);
|
||||
if (selectedSession.value && !timelineSessions.value.some((session) => session.trace_id === selectedSession.value.trace_id)) {
|
||||
selectedDetail.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectSession(session) {
|
||||
if (!session?.trace_id) {
|
||||
return;
|
||||
}
|
||||
await run(`session:${session.trace_id}`, async () => {
|
||||
selectedDetail.value = responseData(await getReleaseTimelineSession(session.trace_id), null);
|
||||
selectedDetailTab.value = "events";
|
||||
});
|
||||
}
|
||||
|
||||
function applyChannelTarget(channel) {
|
||||
Object.assign(replayForm, { target_type: "channel", target_id: channel.slug, channel_id: channel.id });
|
||||
}
|
||||
|
||||
function selectModule(option) {
|
||||
timelineFilters.module_key = option?.value || option || "";
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
selectedDetail.value = null;
|
||||
selectedDetailTab.value = "events";
|
||||
}
|
||||
|
||||
onMounted(loadReplayData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="release-replay-inspector">
|
||||
<div v-if="errors.length" class="notification is-danger is-light">
|
||||
<p v-for="(error, index) in errors" :key="index">{{ parseError(error) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="release-chip-row" data-testid="release-replay-target-suggestions">
|
||||
<b-button
|
||||
v-for="channel in channelOptions"
|
||||
:key="channel.id"
|
||||
size="is-small"
|
||||
type="is-light"
|
||||
icon-left="circle"
|
||||
icon-pack="fas"
|
||||
@click="applyChannelTarget(channel)"
|
||||
>
|
||||
{{ tr("replay.capture_channel", { channel: channel.slug }) }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<form class="release-form" data-testid="release-replay-form" @submit.prevent="enableReplayTarget">
|
||||
<b-field :label="tr('replay.target_type')">
|
||||
<b-select v-model="replayForm.target_type" expanded>
|
||||
<option value="user">{{ valueLabel("subject_type", "user") }}</option>
|
||||
<option value="subuser">{{ valueLabel("subject_type", "subuser") }}</option>
|
||||
<option value="customer">{{ valueLabel("subject_type", "customer") }}</option>
|
||||
<option value="channel">{{ valueLabel("subject_type", "channel") }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field :label="tr('replay.target')" :message="tr('replay.target_message')">
|
||||
<b-input v-model="replayForm.target_id" :placeholder="tr('replay.target_placeholder')" />
|
||||
</b-field>
|
||||
<b-field :label="tr('replay.channel_scope')" :message="tr('replay.channel_scope_message')">
|
||||
<b-select v-model.number="replayForm.channel_id" expanded>
|
||||
<option :value="null">{{ tr("replay.no_channel_scope") }}</option>
|
||||
<option v-for="channel in channelOptions" :key="channel.id" :value="channel.id">{{ channel.slug }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field :label="tr('replay.capture_level')">
|
||||
<b-select v-model="replayForm.capture_level" expanded>
|
||||
<option value="full_redacted">{{ valueLabel("capture_level", "full_redacted") }}</option>
|
||||
<option value="metadata">{{ valueLabel("capture_level", "metadata") }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field :label="tr('replay.expires')" :message="tr('replay.expires_message')">
|
||||
<b-input v-model="replayForm.expires_at" type="datetime-local" />
|
||||
</b-field>
|
||||
<div class="release-form-actions">
|
||||
<b-button
|
||||
type="is-dark"
|
||||
native-type="submit"
|
||||
icon-left="play"
|
||||
icon-pack="fas"
|
||||
:disabled="!props.canReplay"
|
||||
:loading="busy === 'replay:target'"
|
||||
>
|
||||
{{ tr("actions.enable") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="release-form mt-3" data-testid="release-timeline-filter-form" @submit.prevent="loadReplayData">
|
||||
<b-field :label="tr('replay.trace_id')" :message="tr('replay.trace_id_message')">
|
||||
<b-input v-model="timelineFilters.trace_id" :placeholder="tr('replay.trace_id_placeholder')" />
|
||||
</b-field>
|
||||
<b-field :label="tr('replay.channel')">
|
||||
<b-input v-model="timelineFilters.channel_slug" :placeholder="tr('replay.channel_placeholder')" />
|
||||
</b-field>
|
||||
<b-field :label="tr('replay.principal_id')">
|
||||
<b-input v-model="timelineFilters.principal_id" :placeholder="tr('replay.principal_id_placeholder')" />
|
||||
</b-field>
|
||||
<b-field :label="trFallback('replay.customer_number', 'Customer number')">
|
||||
<b-input v-model="timelineFilters.customer_number" placeholder="Customer number" />
|
||||
</b-field>
|
||||
<b-field :label="tr('replay.module')">
|
||||
<BAutocomplete
|
||||
v-model="timelineFilters.module_key"
|
||||
:data="moduleOptions"
|
||||
field="value"
|
||||
:placeholder="tr('replay.module_placeholder')"
|
||||
open-on-focus
|
||||
keep-first
|
||||
expanded
|
||||
@select="selectModule"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<div class="release-autocomplete-option">
|
||||
<i :class="slotProps.option.icon" aria-hidden="true"></i>
|
||||
<span>
|
||||
<strong>{{ slotProps.option.title }}</strong>
|
||||
<small>{{ slotProps.option.description }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</BAutocomplete>
|
||||
</b-field>
|
||||
<b-field :label="tr('replay.severity')">
|
||||
<b-select v-model="timelineFilters.severity" expanded>
|
||||
<option value="">{{ tr("replay.any_severity") }}</option>
|
||||
<option value="error">{{ valueLabel("severity", "error") }}</option>
|
||||
<option value="warning">{{ valueLabel("severity", "warning") }}</option>
|
||||
<option value="info">{{ valueLabel("severity", "info") }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field :label="trFallback('replay.event_type', 'Event type')">
|
||||
<b-input v-model="timelineFilters.event_type" placeholder="request_failed" />
|
||||
</b-field>
|
||||
<b-field :label="trFallback('replay.device_type', 'Device type')">
|
||||
<b-select v-model="timelineFilters.device_type" expanded>
|
||||
<option value="">{{ trFallback("replay.any_device", "Any device") }}</option>
|
||||
<option value="desktop">desktop</option>
|
||||
<option value="tablet">tablet</option>
|
||||
<option value="mobile">mobile</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field :label="trFallback('replay.frontend_release', 'Frontend release')">
|
||||
<b-input v-model="timelineFilters.frontend_version" placeholder="frontend-2026.05.19" />
|
||||
</b-field>
|
||||
<b-field :label="trFallback('replay.api_release', 'API release')">
|
||||
<b-input v-model="timelineFilters.api_version" placeholder="api-2026.05.19" />
|
||||
</b-field>
|
||||
<b-field :label="trFallback('replay.date_from', 'From')">
|
||||
<b-input v-model="timelineFilters.date_from" type="datetime-local" />
|
||||
</b-field>
|
||||
<b-field :label="trFallback('replay.date_to', 'To')">
|
||||
<b-input v-model="timelineFilters.date_to" type="datetime-local" />
|
||||
</b-field>
|
||||
<b-field :label="trFallback('replay.error_reports', 'Error reports')">
|
||||
<b-checkbox v-model="timelineFilters.has_error_report">
|
||||
{{ trFallback("replay.has_error_report", "Has error report") }}
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
<div class="release-form-actions">
|
||||
<b-button
|
||||
native-type="submit"
|
||||
icon-left="search"
|
||||
icon-pack="fas"
|
||||
:disabled="!props.canReplay"
|
||||
:loading="busy === 'timeline:search'"
|
||||
>
|
||||
{{ tr("actions.search") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="timelineRefreshing"
|
||||
class="release-replay-inspector__refreshing"
|
||||
data-testid="release-timeline-refreshing"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span>{{ trFallback("loading.refreshing_timeline", "Refreshing release timeline...") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="table-container mt-3">
|
||||
<table class="table is-fullwidth is-hoverable release-session-table" data-testid="release-timeline-sessions">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tr("replay.trace_id") }}</th>
|
||||
<th>{{ trFallback("replay.user", "User") }}</th>
|
||||
<th>{{ trFallback("replay.device", "Device") }}</th>
|
||||
<th>{{ tr("replay.channel") }}</th>
|
||||
<th>{{ trFallback("replay.release", "Release") }}</th>
|
||||
<th>{{ trFallback("replay.events", "Events") }}</th>
|
||||
<th>{{ trFallback("replay.last_seen", "Last seen") }}</th>
|
||||
<th>{{ trFallback("actions.inspect", "Inspect") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="sessionRowsLoading" data-testid="release-timeline-sessions-loading">
|
||||
<td colspan="8">
|
||||
<div class="release-replay-inspector__loading" role="status" aria-live="polite">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span>{{ trFallback("loading.timeline_sessions", "Loading release timeline sessions...") }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr v-for="session in sessionRows" :key="session.trace_id">
|
||||
<td><code>{{ shortValue(session.trace_id) }}</code></td>
|
||||
<td>{{ userLabel(session) }}</td>
|
||||
<td>{{ deviceLabel(session) }}</td>
|
||||
<td>{{ session.channel_slug || "--" }}</td>
|
||||
<td>{{ releaseLabel(session, "frontend") }} / {{ releaseLabel(session, "api") }}</td>
|
||||
<td>
|
||||
{{ session.event_count || 0 }}
|
||||
<b-tag v-if="session.error_count" type="is-danger" size="is-small">{{ session.error_count }}</b-tag>
|
||||
<b-tag v-if="session.error_report_count" type="is-warning" size="is-small">
|
||||
{{ session.error_report_count }}
|
||||
</b-tag>
|
||||
</td>
|
||||
<td>{{ formatDate(session.last_event_at || session.last_seen_at) }}</td>
|
||||
<td class="release-action-cell">
|
||||
<ActionSettingsWheelButton
|
||||
class="release-session-actions"
|
||||
:data-testid="`release-session-actions-${session.trace_id}`"
|
||||
>
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
icon="fas fa-search"
|
||||
:label="trFallback('actions.inspect', 'Inspect')"
|
||||
:click-action="() => inspectSession(session)"
|
||||
:disabled="busy === `session:${session.trace_id}`"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!sessionRowsLoading && sessionRows.length === 0">
|
||||
<td colspan="8">{{ tr("replay.no_timeline_events") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="selectedDetail"
|
||||
class="modal is-active release-inspector-modal"
|
||||
data-testid="release-timeline-session-modal"
|
||||
>
|
||||
<div class="modal-background release-inspector-modal__backdrop" @click="closeDetailModal"></div>
|
||||
<section class="release-inspector-modal__card" data-testid="release-timeline-session-detail">
|
||||
<header class="release-inspector-modal__header">
|
||||
<div>
|
||||
<p class="release-inspector-modal__eyebrow">{{ trFallback("replay.debug_inspection", "Debug inspection") }}</p>
|
||||
<h3>{{ userLabel(selectedSession) }}</h3>
|
||||
<p class="release-inspector-modal__trace"><code>{{ selectedSession.trace_id }}</code></p>
|
||||
</div>
|
||||
<div class="release-inspector-modal__meta">
|
||||
<b-tag :class="statusClass(selectedSession.error_count ? 'error' : 'info')">
|
||||
{{ selectedSession.error_count ? "errors" : "info" }}
|
||||
</b-tag>
|
||||
<button class="delete" type="button" aria-label="close" @click="closeDetailModal"></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="release-inspector-modal__summary">
|
||||
<span>{{ selectedSession.channel_slug || "--" }}</span>
|
||||
<span>{{ deviceLabel(selectedSession) }}</span>
|
||||
<span>{{ releaseLabel(selectedSession, "frontend") }} / {{ releaseLabel(selectedSession, "api") }}</span>
|
||||
<span>{{ selectedSession.event_count || 0 }} {{ trFallback("replay.events", "Events") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="release-inspector-modal__tabs" role="tablist">
|
||||
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'events' }" type="button" @click="selectedDetailTab = 'events'">
|
||||
{{ trFallback("replay.events", "Events") }}
|
||||
</button>
|
||||
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'release' }" type="button" @click="selectedDetailTab = 'release'">
|
||||
{{ trFallback("replay.release", "Release") }}
|
||||
</button>
|
||||
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'device' }" type="button" @click="selectedDetailTab = 'device'">
|
||||
{{ trFallback("replay.device", "Device") }}
|
||||
</button>
|
||||
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'reports' }" type="button" @click="selectedDetailTab = 'reports'">
|
||||
{{ trFallback("replay.error_reports", "Error reports") }}
|
||||
</button>
|
||||
<button class="button is-small" type="button" disabled>
|
||||
{{ trFallback("replay.visual_replay", "Visual replay") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="release-inspector-modal__body">
|
||||
<div v-if="selectedDetailTab === 'events'" class="release-detail-panel">
|
||||
<ol class="release-timeline" data-testid="release-timeline-events">
|
||||
<li v-for="event in detailEvents" :key="event.id">
|
||||
<time>{{ formatDate(event.occurred_at) }}</time>
|
||||
<strong>{{ event.event_type }}</strong>
|
||||
<b-tag class="ml-1" :class="statusClass(event.severity)">{{ valueLabel("severity", event.severity) }}</b-tag>
|
||||
<div class="release-muted">
|
||||
{{ event.channel_slug || "--" }} / {{ event.module_key || "--" }} /
|
||||
{{ event.route_path || event.component || "--" }}
|
||||
</div>
|
||||
<details>
|
||||
<summary>{{ trFallback("replay.payload", "Payload") }}</summary>
|
||||
<pre>{{ jsonPreview(event.payload) }}</pre>
|
||||
</details>
|
||||
</li>
|
||||
<li v-if="detailEvents.length === 0">{{ tr("replay.no_timeline_events") }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedDetailTab === 'release'" class="release-detail-panel release-kv-grid">
|
||||
<span>Channel</span><strong>{{ selectedSession.channel_slug || "--" }}</strong>
|
||||
<span>Frontend</span><strong>{{ releaseApp("frontend").version_label || releaseLabel(selectedSession, "frontend") }}</strong>
|
||||
<span>Frontend commit</span><strong>{{ releaseApp("frontend").commit_sha || "--" }}</strong>
|
||||
<span>Frontend version ref</span><strong>{{ versionReferenceLabel("frontend") }}</strong>
|
||||
<span>Frontend deployment</span><strong>{{ deploymentReferenceLabel("frontend") }}</strong>
|
||||
<span>API</span><strong>{{ releaseApp("api").version_label || releaseLabel(selectedSession, "api") }}</strong>
|
||||
<span>API commit</span><strong>{{ releaseApp("api").commit_sha || "--" }}</strong>
|
||||
<span>API version ref</span><strong>{{ versionReferenceLabel("api") }}</strong>
|
||||
<span>API deployment</span><strong>{{ deploymentReferenceLabel("api") }}</strong>
|
||||
<span>Bundle</span><strong>{{ bundleReferenceLabel() }}</strong>
|
||||
<span>Route</span><strong>{{ selectedSession.last_route_path || "--" }}</strong>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedDetailTab === 'device'" class="release-detail-panel release-kv-grid">
|
||||
<span>Type</span><strong>{{ selectedSession.device?.type || "--" }}</strong>
|
||||
<span>Browser</span><strong>{{ selectedSession.device?.browser_name || "--" }} {{ selectedSession.device?.browser_version || "" }}</strong>
|
||||
<span>OS</span><strong>{{ selectedSession.device?.os_name || "--" }} {{ selectedSession.device?.os_version || "" }}</strong>
|
||||
<span>Viewport</span><strong>{{ viewportLabel(selectedSession) }}</strong>
|
||||
<span>User agent</span><strong class="release-break-word">{{ selectedSession.device?.user_agent || "--" }}</strong>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedDetailTab === 'reports'" class="release-detail-panel">
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Status</th>
|
||||
<th>Reporter</th>
|
||||
<th>Route</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="report in detailErrorReports" :key="report.id">
|
||||
<td>
|
||||
<router-link to="/superuser/error-reports">#{{ report.id }}</router-link>
|
||||
</td>
|
||||
<td>{{ report.status }}</td>
|
||||
<td>{{ report.reporter?.name || report.reporter?.email || report.reporter?.type || "--" }}</td>
|
||||
<td>{{ report.route_path || "--" }}</td>
|
||||
<td>{{ formatDate(report.created_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="detailErrorReports.length === 0">
|
||||
<td colspan="5">{{ trFallback("replay.no_error_reports", "No linked error reports.") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="release-muted mt-2">
|
||||
{{ trFallback("replay.visual_replay_disabled", "Visual replay requires a future visual_redacted capture mode.") }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div
|
||||
v-if="!selectedDetail && timelineEventsLoading"
|
||||
class="release-replay-inspector__loading"
|
||||
data-testid="release-timeline-events-loading"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span>{{ trFallback("loading.timeline_events", "Loading release timeline events...") }}</span>
|
||||
</div>
|
||||
<ol v-else-if="!selectedDetail" class="release-timeline" data-testid="release-timeline-events">
|
||||
<li v-for="event in timelineEvents" :key="event.id">
|
||||
<time>{{ formatDate(event.occurred_at) }}</time>
|
||||
<strong>{{ event.event_type }}</strong>
|
||||
<b-tag class="ml-1" :class="statusClass(event.severity)">{{ valueLabel("severity", event.severity) }}</b-tag>
|
||||
<div class="release-muted">
|
||||
{{ event.channel_slug || "--" }} / {{ event.module_key || "--" }} /
|
||||
{{ event.route_path || event.component || "--" }}
|
||||
</div>
|
||||
<details>
|
||||
<summary>{{ trFallback("replay.payload", "Payload") }}</summary>
|
||||
<pre>{{ jsonPreview(event.payload) }}</pre>
|
||||
</details>
|
||||
</li>
|
||||
<li v-if="timelineEvents.length === 0">{{ tr("replay.no_timeline_events") }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-replay-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.release-replay-inspector__loading,
|
||||
.release-replay-inspector__refreshing {
|
||||
align-items: center;
|
||||
color: #52627a;
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.release-replay-inspector__refreshing {
|
||||
background: #eef6ff;
|
||||
border: 1px solid #cfe3ff;
|
||||
border-radius: 6px;
|
||||
color: #17416f;
|
||||
justify-content: flex-start;
|
||||
min-height: 0;
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.release-session-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.release-action-cell {
|
||||
text-align: right;
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.release-inspector-modal {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.release-inspector-modal__backdrop {
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
}
|
||||
|
||||
.release-inspector-modal__card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 28px 90px rgba(15, 23, 42, 0.34);
|
||||
color: #111827;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(86vh, 900px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: min(1120px, calc(100vw - 2rem));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.release-inspector-modal__header {
|
||||
align-items: flex-start;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.release-inspector-modal__header h3 {
|
||||
color: #111827;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.release-inspector-modal__eyebrow {
|
||||
color: #6b7280;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-inspector-modal__trace {
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.release-inspector-modal__meta {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.release-inspector-modal__summary {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.release-inspector-modal__summary span {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 999px;
|
||||
color: #374151;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.65rem;
|
||||
}
|
||||
|
||||
.release-inspector-modal__tabs {
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.9rem 1.5rem;
|
||||
}
|
||||
|
||||
.release-inspector-modal__body {
|
||||
background: #ffffff;
|
||||
overflow: auto;
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.release-detail-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 6px;
|
||||
color: #111827;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.release-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9rem, 12rem) minmax(0, 1fr);
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.release-kv-grid span {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.release-break-word {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.release-timeline pre {
|
||||
background: #111827;
|
||||
border-radius: 6px;
|
||||
color: #f9fafb;
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.5rem;
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.release-inspector-modal__card {
|
||||
max-height: 92vh;
|
||||
width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.release-inspector-modal__header {
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.release-inspector-modal__summary,
|
||||
.release-inspector-modal__tabs,
|
||||
.release-inspector-modal__body {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.release-kv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user