Compare commits

...
Author SHA1 Message Date
Jeppe B dad42da6d9 Keep vehicle actions in wheel menu 2026-05-28 19:01:27 +02:00
Jeppe Bundgaard 0fa2284e89 Add connectivity issue handling logic and tests
- Add `ConnectivityIssue.vue` component for displaying server connection issues.
- Implement polling logic with retry intervals to check server health.
- Update i18n with new connectivity issue messages.
- Add unit tests to cover connectivity failure and recovery scenarios.
- Update test suite for breadth of coverage, including router contract and timer cleanup.
2026-05-28 18:11:21 +02:00
Jeppe Bundgaard cc241e5e19 Add subuser support and require notes for specific products 2026-05-28 16:36:42 +02:00
Jeppe Bundgaard c5a536fbea Add subuser support and require notes for specific products 2026-05-28 16:12:41 +02:00
Jeppe Bundgaard 3a7fef11ef Implement subuser password policy validation and update related components 2026-05-27 19:16:26 +02:00
Jeppe Bundgaard 625757fafc Fix Danish translations and enhance mobile department auto-selection logic 2026-05-27 17:34:51 +02:00
Jeppe Bundgaard e1a8af5a72 Extend i18n with new self-serve prompts and lane availability messages, and add E2E test for lane confirmation logic. 2026-05-27 13:25:31 +02:00
Jeppe Bundgaard c437c89cfa Add release management enhancements, runtime-aware frontend updates, and extensive test coverage
- Implement release update detection, asset preloading, and frontend version management.
- Add unit and E2E tests for release update workflows, widget behavior, and failure scenarios.
- Introduce new services for handling release ping, error reporting, and update installation workflows.
- Extend i18n for release-related components and error report localization.
- Add `ReleaseFrontendVersionBadge.vue` and related styles to display frontend update statuses.
2026-05-27 13:23:22 +02:00
Jeppe Bundgaard 184ef5670c Enhance session management by normalizing session payload and resetting session state on logout. Update login redirect logic and improve runtime API URL handling for selected release channels. 2026-05-26 17:28:21 +02:00
Jeppe Bundgaard 8ab8915429 Add release management components and update routing logic. Introduce keyboard shortcuts, enhance release data grid, and improve asset handling in Nginx configuration. 2026-05-26 14:14:13 +02:00
Jeppe B a952dd2c61 Merge pull request #52
Add endpoint mode options and enhance runtime refresh handling
2026-05-21 14:48:44 +02:00
Jeppe Bundgaard ebcd52a019 Add endpoint mode options, release channel rollback handling, and runtime refresh improvements
- Implement `endpoint_mode` (manual/auto) and related configuration in i18n across multiple locales.
- Add new utility methods for channel rollback and runtime confirmation workflows.
- Update unit and E2E tests to cover runtime refresh behavior, channel switching mechanics, and release references.
- Enhance `SessionUser` and related services to improve error handling during release runtime refresh.
- Add data-test attributes for frontend components to support improved test coverage.
2026-05-21 14:47:46 +02:00
Jeppe Bundgaard b2ddf0db5c Add beta frontend debug display in build info 2026-05-21 11:45:20 +02:00
Jeppe Bundgaard eaf4b965d5 Add runtime support for service sets, release management enhancements, and new unit tests
- Extend `releaseTimeline.js` and related modules for runtime-aware service sets and URLs.
- Update the release session builder to include `service_set` handling.
- Introduce release service definitions, status tones, and enhanced runtime display text.
- Add new dataset mode options, preview rows, and warnings for release configurations in `ConfigurationReleaseManager.vue`.
- Implement extensive utility methods for data service handling, target context, and deployment endpoints.
- Add `vite-api-proxy.spec.js` and extend `release-bootstrap.spec.js` to ensure coverage for new functionality.
2026-05-21 11:41:50 +02:00
Jeppe Bundgaard 6ba1c754a1 Serve frontend releases under channel prefixes 2026-05-20 19:27:01 +02:00
Jeppe Bundgaard 468613ab68 Add Coolify frontend release Docker image 2026-05-20 18:53:17 +02:00
Jeppe Bundgaard 261d84bd01 Route selected release runtime through gateway 2026-05-20 18:32:21 +02:00
Jeppe Bundgaard ea8db593c5 Add release headers service, runtime-aware API header handling, and i18n updates for release configuration
- Implement `releaseHeaders.js` to manage release-related HTTP headers.
- Update requests to include runtime-based release headers.
- Extend i18n phrases across multiple locales for release runtime and configuration labels.
- Enhance unit tests for header building and API availability error handling.
2026-05-20 17:52:54 +02:00
Jeppe Bundgaard 130cc2fc10 Add release runtime bootstrap implementation with unit and E2E tests
- Create `releaseBootstrap.js` service to handle runtime configuration and remote frontend loading.
- Integrate runtime-aware API URL resolution across components and services.
- Update i18n phrases to clarify new domains for `ssl_domain_message`.
- Enhance unit and E2E tests to validate runtime-based frontend loading and fallback behavior.
- Refactor session-related API calls and incorporate runtime-configured base URLs.
2026-05-20 11:26:21 +02:00
Jeppe Bundgaard 3715da8fd3 Add release channel selector components, subject autocomplete, and related tests
- Implement components for `ReleaseChannelSelector`, including panel and sidebar variants.
- Add `ReleaseAssignmentSubjectAutocomplete` for user, subuser, and customer search functionality.
- Update i18n phrases for channel selector and subject assignment.
- Enhance unit and E2E tests for release channel availability, switching, and runtime configuration.
- Modify modules for channel-related state handling and runtime refresh mechanics.
2026-05-20 10:31:49 +02:00
Jeppe Bundgaard 100e477a95 Add "Delete" functionality for release service sets in isolated stacks
- Implement deletion of inactive isolated stacks via API and UI.
- Normalize build pack handling, defaulting frontend to "static".
- Update E2E tests to verify deletion and adjusted UI behavior.
- Refactor related release manager methods and validation logic.
2026-05-19 16:55:20 +02:00
Jeppe Bundgaard 1132c8c256 Merge remote-tracking branch 'origin/master' 2026-05-19 14:47:01 +02:00
Jeppe Bundgaard 42237fcc70 Add unit tests for release manager i18n validation and implement ReleaseReplayInspector Vue component for timeline inspection and replay functionality. 2026-05-19 14:46:42 +02:00
gold-dev ae8b3b6b64 Feat: Hvidovre only one toggler 2026-05-19 14:26:23 +03:00
Jeppe B 8378491b0b Merge pull request #51
Fix chromium mobile runner flakes
2026-05-19 13:12:11 +02:00
262 changed files with 38037 additions and 5745 deletions
+17
View File
@@ -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
+15
View File
@@ -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
+142
View File
@@ -0,0 +1,142 @@
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://dev.truckwash.io
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
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: 30
RELEASE_WAIT_TIMEOUT_SECONDS: 300
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: Install lftp
run: |
if ! command -v lftp >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y lftp
fi
- name: Upload hashed assets before release metadata
run: |
test -n "$RELEASE_DEPLOY_HOST" || (echo "RELEASE_DEPLOY_HOST is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_USER" || (echo "RELEASE_DEPLOY_USER is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_PASSWORD" || (echo "RELEASE_DEPLOY_PASSWORD is required" >&2; exit 1)
npm run release:upload:lftp
env:
RELEASE_DEPLOY_HOST: ${{ secrets.RELEASE_DEPLOY_HOST }}
RELEASE_DEPLOY_USER: ${{ secrets.RELEASE_DEPLOY_USER }}
RELEASE_DEPLOY_PASSWORD: ${{ secrets.RELEASE_DEPLOY_PASSWORD }}
RELEASE_DEPLOY_REMOTE_ROOT: ${{ secrets.RELEASE_DEPLOY_REMOTE_ROOT }}
- name: Wait for exact uploaded build
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
+18
View File
@@ -0,0 +1,18 @@
FROM node:24-alpine AS build
WORKDIR /app
RUN apk add --no-cache git
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY . .
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
View File
@@ -22,6 +22,28 @@ 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
```
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
Vendored
+1
View File
@@ -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
View File
@@ -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>
+54
View File
@@ -0,0 +1,54 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
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";
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";
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
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;
}
}
+5
View File
@@ -42,7 +42,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",
+33 -29
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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"
+50
View File
@@ -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);
});
+53
View File
@@ -0,0 +1,53 @@
#!/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://${USER_NAME}:${PASSWORD}@${HOST}"
fi
upload_file() {
local source_file="$1"
local remote_file="$2"
if [[ -f "$source_file" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
fi
}
for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
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"
+215
View File
@@ -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);
});
+36 -17
View File
@@ -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;
}
}
}
-2
View File
@@ -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>
+3
View File
@@ -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 -12
View File
@@ -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>
+33 -7
View File
@@ -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,
@@ -839,6 +839,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 +895,30 @@ 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 getAttachmentExtension = (attachment) => {
const match = getAttachmentLabel(attachment)
const match = String(getAttachmentLabel(attachment))
.toLowerCase()
.match(/(\.[a-z0-9]+)$/);
@@ -894,17 +940,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);
@@ -992,7 +1072,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 +1609,90 @@ const showCompleteOrderBookingConfirmation = async () => {
}
};
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 flatBuiltInMenuSections = computed(() => {
const sections = [];
@@ -1614,6 +1778,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",
@@ -2477,7 +2651,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 +3041,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>
@@ -1260,6 +1260,8 @@ 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"
:refreshFunction="loadList"
@deleted="loadList()"
@@ -1501,6 +1503,8 @@ 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"
:refreshFunction="loadList"
@deleted="loadList()"
@@ -1992,6 +1996,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"
@@ -297,7 +297,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 +660,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;
}
@@ -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>
@@ -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>
@@ -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 -->
@@ -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;
}
@@ -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>
@@ -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",
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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">&nbsp;</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">&nbsp;</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>
@@ -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,5 +1,5 @@
<script setup lang="ts">
import { BField, BIcon, BMessage, BRadioButton } from "buefy";
import { BIcon, BMessage, BRadioButton } from "buefy";
defineProps<{
lanes: Array<any>;
@@ -19,14 +19,23 @@ const emit = defineEmits<{
<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 +45,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 +58,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,17 +81,20 @@ 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)"
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')"
@@ -105,6 +117,86 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</div>
</b-field>
<b-message
v-if="selectedLaneId && !isMachineAvailable(selectedLaneId)"
type="is-warning"
has-icon
:closable="false"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</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,15 @@ 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
v-if="isLoading"
class="notification is-info is-light py-2 px-3 mb-0"
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>
<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 +60,16 @@ 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%;
}
</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 { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
defineProps<{
const props = defineProps<{
isLoading: boolean;
dynamicImageUrl: string | null;
activeTasks: Array<any>;
@@ -17,6 +18,12 @@ const emit = defineEmits<{
(e: "clear-dynamic-image"): void;
}>();
const isDynamicImageLoading = ref(false);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
}, { immediate: true });
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
};
@@ -24,6 +31,15 @@ const emitToggleTask = (taskId: number, value: boolean) => {
const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined, attachment?: any) => {
emit("download-attachment", taskId, attachmentId, attachment);
};
const onDynamicImageLoad = () => {
isDynamicImageLoading.value = false;
};
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
emit("clear-dynamic-image");
};
</script>
<template>
@@ -42,13 +58,28 @@ 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">
<div
v-if="dynamicImageUrl"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="dynamicImageUrl"
: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')"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
</div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
@@ -64,3 +95,41 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
</div>
</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>
@@ -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 et af ikonerne ovenfor.
</template>
</p>
</div>
@@ -8,14 +8,20 @@ const props = defineProps({
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 formatDateTime = (dateString) => {
@@ -50,6 +56,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 +177,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 +187,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 +200,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}`">
@@ -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>
@@ -235,7 +235,7 @@ const getProductOptionsLabel = (vehicle) => {
/>
<!-- Actions -->
<td>
<!-- Actions -->
<!-- Actions Should not be shown directly! -->
<ActionSettingsWheelButton
v-if="!props.compact"
:user_id="object.user_id"
+19 -2
View File
@@ -32,6 +32,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', {
@@ -55,8 +73,7 @@ const login = async () => {
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) {
@@ -7,6 +7,7 @@ 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";
const { t } = useI18n();
@@ -39,6 +40,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 };
@@ -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(() => {
+40 -5
View File
@@ -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">
+299 -57
View File
@@ -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,
@@ -268,16 +273,19 @@ 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 releaseSessionSummary = computed(() => buildReleaseSessionSummary());
const measurePingLatency = async () => {
if (typeof fetch !== "function") {
pingLatencyMs.value = null;
@@ -404,7 +412,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 +646,186 @@ 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"
<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 class="request-queue-progress__subsection-title">Runtime details</div>
<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="activeApiUrl">{{ activeApiUrl }}</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 +1062,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 +1307,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,
+3 -1
View File
@@ -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>
+61 -9
View File
@@ -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">-&gt;</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>
@@ -0,0 +1,71 @@
<script setup>
defineProps({
tabs: {
type: Array,
default: () => [],
},
activeKey: {
type: String,
default: "overview",
},
});
const emit = defineEmits(["navigate"]);
</script>
<template>
<nav class="release-route-tabs" data-testid="release-management-interface" aria-label="Release Manager">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="release-route-tabs__item"
:class="{ 'release-route-tabs__item--active': activeKey === tab.key }"
:data-testid="`release-section-link-${tab.key}`"
@click="emit('navigate', tab.key)"
>
<i :class="`fas fa-${tab.icon}`" aria-hidden="true"></i>
<span>{{ tab.label }}</span>
</button>
</nav>
</template>
<style scoped>
.release-route-tabs {
align-items: center;
border-bottom: 1px solid #d8dee8;
display: flex;
flex-wrap: nowrap;
gap: 0.25rem;
min-height: 2.75rem;
overflow-x: auto;
padding: 0 0.25rem;
}
.release-route-tabs__item {
align-items: center;
background: transparent;
border: 0;
border-bottom: 3px solid transparent;
color: #52627a;
cursor: pointer;
display: inline-flex;
flex: 0 0 auto;
font: inherit;
font-size: 0.9rem;
gap: 0.45rem;
min-height: 2.75rem;
padding: 0 0.75rem;
}
.release-route-tabs__item--active {
border-bottom-color: #1f6feb;
color: #172033;
font-weight: 700;
}
.release-route-tabs__item:focus-visible {
outline: 2px solid #1f6feb;
outline-offset: -2px;
}
</style>
@@ -0,0 +1,232 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, watch } from "vue";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
import {
checkReleaseUpdateNow,
downloadReleaseCandidate,
inspectReleaseRuntimeForUpdate,
installReadyReleaseUpdate,
isSameReleaseCommit,
releaseUpdateState,
shortReleaseCommit,
startReleaseUpdateAutoCheck,
} from "@/services/releaseUpdate.js";
let stopAutoCheck = null;
const updateAvailable = computed(
() =>
Boolean(releaseUpdateState.latestCommit) &&
!isSameReleaseCommit(releaseUpdateState.currentCommit, releaseUpdateState.latestCommit)
);
const isVisible = computed(
() => updateAvailable.value && ["downloading", "ready", "failed"].includes(releaseUpdateState.status)
);
const progress = computed(() => Math.max(0, Math.min(100, Number(releaseUpdateState.progress || 0))));
const progressStyle = computed(() => ({ width: `${progress.value}%` }));
const latestCommitText = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit));
const currentCommitText = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const statusLabel = computed(() => {
if (releaseUpdateState.status === "ready") {
return "Ready";
}
if (releaseUpdateState.status === "failed") {
return "Failed";
}
return "Downloading";
});
const runtimeSignature = computed(() =>
JSON.stringify({
channel: releaseRuntimeState.channel?.slug || "",
commit: releaseRuntimeState.versions?.frontend?.commit_sha || releaseRuntimeState.versions?.frontend?.commit || "",
frontendBaseUrl: releaseRuntimeState.frontendBaseUrl || "",
configured: releaseRuntimeState.availability?.configured !== false,
})
);
const hasRuntimeCandidate = () =>
Boolean(releaseRuntimeState.channel || releaseRuntimeState.frontendBaseUrl || releaseRuntimeState.versions?.frontend);
const inspectCurrentRuntime = () => {
if (!hasRuntimeCandidate()) {
return;
}
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
};
const retryDownload = () => {
if (releaseUpdateState.candidate) {
void downloadReleaseCandidate(releaseUpdateState.candidate);
return;
}
void checkReleaseUpdateNow();
};
const installUpdate = () => {
void installReadyReleaseUpdate();
};
onMounted(() => {
stopAutoCheck = startReleaseUpdateAutoCheck();
inspectCurrentRuntime();
});
onBeforeUnmount(() => {
stopAutoCheck?.();
stopAutoCheck = null;
});
watch(runtimeSignature, inspectCurrentRuntime, { immediate: true });
</script>
<template>
<section v-if="isVisible" class="release-update-widget" data-testid="release-update-widget">
<div class="release-update-widget__topline">
<span class="release-update-widget__label">{{ statusLabel }}</span>
<span class="release-update-widget__commits">{{ currentCommitText }} -&gt; {{ latestCommitText }}</span>
</div>
<template v-if="releaseUpdateState.status === 'ready'">
<button
type="button"
class="release-update-widget__install"
data-testid="release-update-install"
@click="installUpdate"
>
Install
</button>
</template>
<template v-else-if="releaseUpdateState.status === 'failed'">
<div class="release-update-widget__failed">
<span>{{ releaseUpdateState.error || "Download failed." }}</span>
<button type="button" data-testid="release-update-retry" @click="retryDownload">Retry</button>
</div>
</template>
<template v-else>
<div
class="release-update-widget__progress"
role="progressbar"
:aria-valuenow="progress"
aria-valuemin="0"
aria-valuemax="100"
data-testid="release-update-progress"
>
<span :style="progressStyle"></span>
</div>
<div class="release-update-widget__progress-meta">
<span>{{ progress }}%</span>
<span v-if="releaseUpdateState.totalAssets">
{{ releaseUpdateState.downloadedAssets }}/{{ releaseUpdateState.totalAssets }}
</span>
</div>
</template>
</section>
</template>
<style scoped>
.release-update-widget {
margin: -2px 16px 14px;
padding: 9px 10px;
border: 1px solid #d7e2ef;
border-radius: 5px;
background: #f8fbff;
color: #1f2937;
}
.release-update-widget__topline,
.release-update-widget__progress-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.release-update-widget__label {
color: #10243f;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.release-update-widget__commits {
min-width: 0;
overflow: hidden;
color: #475569;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.68rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.release-update-widget__progress {
height: 7px;
margin-top: 8px;
border-radius: 999px;
background: #dbe5f0;
overflow: hidden;
}
.release-update-widget__progress span {
display: block;
height: 100%;
border-radius: inherit;
background: #153554;
transition: width 0.18s ease;
}
.release-update-widget__progress-meta {
margin-top: 5px;
color: #64748b;
font-size: 0.68rem;
font-weight: 700;
}
.release-update-widget__install {
width: 100%;
min-height: 30px;
margin-top: 8px;
border: 0;
border-radius: 4px;
background: #153554;
color: #ffffff;
cursor: pointer;
font-size: 0.78rem;
font-weight: 800;
}
.release-update-widget__install:hover {
background: #10243f;
}
.release-update-widget__failed {
display: grid;
gap: 7px;
margin-top: 7px;
}
.release-update-widget__failed span {
color: #b42318;
font-size: 0.7rem;
font-weight: 700;
line-height: 1.25;
}
.release-update-widget__failed button {
min-height: 28px;
border: 1px solid #f4a3a3;
border-radius: 4px;
background: #fff5f5;
color: #9f1c1c;
cursor: pointer;
font-size: 0.74rem;
font-weight: 800;
}
@media (prefers-reduced-motion: reduce) {
.release-update-widget__progress span {
transition: none;
}
}
</style>
@@ -0,0 +1,208 @@
<script setup>
import { useAttrs } from "vue";
defineOptions({
inheritAttrs: false,
});
const attrs = useAttrs();
defineProps({
title: {
type: String,
default: "",
},
subtitle: {
type: String,
default: "",
},
icon: {
type: String,
default: "fas fa-table-columns",
},
padded: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading release data...",
},
loadingDetail: {
type: String,
default: "",
},
loadingRows: {
type: Number,
default: 4,
},
});
</script>
<template>
<section
v-bind="attrs"
class="release-workspace-panel"
:data-testid="attrs['data-testid'] || 'release-workspace-panel'"
:aria-busy="loading"
>
<header class="release-workspace-panel__header">
<div>
<span class="release-workspace-panel__kicker">
<i :class="icon" aria-hidden="true"></i>
Release Manager
</span>
<h3>{{ title }}</h3>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="release-workspace-panel__actions">
<slot name="actions" />
</div>
</header>
<div
v-if="loading"
class="release-workspace-panel__body release-workspace-panel__body--padded"
data-testid="release-workspace-panel-loading"
role="status"
aria-live="polite"
>
<div class="release-workspace-panel__loading">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<div>
<strong>{{ loadingText }}</strong>
<span v-if="loadingDetail">{{ loadingDetail }}</span>
</div>
</div>
<div class="release-workspace-panel__skeleton" aria-hidden="true">
<span v-for="index in loadingRows" :key="index"></span>
</div>
</div>
<div v-else class="release-workspace-panel__body" :class="{ 'release-workspace-panel__body--padded': padded }">
<slot />
</div>
</section>
</template>
<style scoped>
.release-workspace-panel {
background: #ffffff;
border: 1px solid #d8dee8;
border-radius: 8px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
}
.release-workspace-panel__header {
align-items: center;
border-bottom: 1px solid #e1e7ef;
display: flex;
gap: 1rem;
justify-content: space-between;
min-height: 4rem;
padding: 0.75rem 1rem;
}
.release-workspace-panel__header h3,
.release-workspace-panel__header p {
margin: 0;
}
.release-workspace-panel__header h3 {
color: #172033;
font-size: 1rem;
line-height: 1.25;
}
.release-workspace-panel__header p,
.release-workspace-panel__kicker {
color: #667085;
font-size: 0.82rem;
}
.release-workspace-panel__kicker {
align-items: center;
display: inline-flex;
font-weight: 700;
gap: 0.35rem;
text-transform: uppercase;
}
.release-workspace-panel__actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.release-workspace-panel__body {
min-height: 0;
overflow: auto;
}
.release-workspace-panel__body--padded {
padding: 1rem;
}
.release-workspace-panel__loading {
align-items: center;
color: #172033;
display: flex;
gap: 0.75rem;
}
.release-workspace-panel__loading > div {
display: grid;
gap: 0.15rem;
}
.release-workspace-panel__loading span {
color: #667085;
font-size: 0.86rem;
}
.release-workspace-panel__skeleton {
display: grid;
gap: 0.65rem;
margin-top: 1rem;
}
.release-workspace-panel__skeleton span {
animation: release-loading-shimmer 1.2s ease-in-out infinite;
background: linear-gradient(90deg, #edf2f7 0%, #f8fafc 50%, #edf2f7 100%);
background-size: 220% 100%;
border-radius: 6px;
display: block;
height: 2.25rem;
}
@keyframes release-loading-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (max-width: 768px) {
.release-workspace-panel {
display: block;
}
.release-workspace-panel__header {
align-items: stretch;
flex-direction: column;
}
.release-workspace-panel__body {
overflow: visible;
}
}
</style>
@@ -0,0 +1,236 @@
const UNKNOWN = "unknown";
const NOT_CONFIGURED = "not configured";
const NOT_CHECKED = "not checked";
const isBlank = (value) => value === null || value === undefined || String(value).trim() === "";
const valueOr = (value, fallback = UNKNOWN) => (isBlank(value) ? fallback : String(value));
const boolValue = (value, trueLabel = "yes", falseLabel = "no", fallback = UNKNOWN) => {
if (value === true || value === 1 || value === "1") return trueLabel;
if (value === false || value === 0 || value === "0") return falseLabel;
return fallback;
};
const shortSha = (value) => {
const normalized = String(value || "").trim();
return normalized.length > 12 ? normalized.slice(0, 12) : normalized || UNKNOWN;
};
const publicRouteSlug = (entity = {}) => {
const slug = String(entity.route_slug || entity.public_route_slug || entity.channel_slug || entity.slug || "").trim();
if (!slug) return UNKNOWN;
return slug.toLowerCase() === "stable" ? "master" : slug;
};
const readableDuration = (start, end) => {
const started = Date.parse(start || "");
const finished = Date.parse(end || "");
if (!Number.isFinite(started) || !Number.isFinite(finished) || finished < started) {
return UNKNOWN;
}
const seconds = Math.round((finished - started) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
return `${Math.round(minutes / 60)}h`;
};
const uptimeText = (entity = {}) => {
const direct = entity.uptime || entity.uptime_text || entity.running_for || entity.uptime_human;
if (!isBlank(direct)) return String(direct);
const seconds = Number(entity.uptime_seconds || entity.uptime_sec || entity.last_status?.uptime_seconds);
if (!Number.isFinite(seconds) || seconds <= 0) return UNKNOWN;
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
return `${Math.round(seconds / 86400)}d`;
};
const endpointHost = (entity = {}) =>
entity.hostname ||
entity.host ||
entity.ip ||
entity.endpoint?.host ||
entity.replication?.host ||
entity.target?.endpoint?.host ||
entity.target?.replication?.host;
const endpointPort = (entity = {}) =>
entity.port || entity.endpoint?.port || entity.replication?.port || entity.target?.endpoint?.port || entity.target?.replication?.port;
const lastCheck = (entity = {}) =>
entity.last_checked_at ||
entity.checked_at ||
entity.health_checked_at ||
entity.last_health_check_at ||
entity.last_check ||
entity.replication?.last_checked_at ||
entity.target?.last_reconciled_at ||
entity.target?.replication?.last_checked_at ||
NOT_CHECKED;
const addFact = (facts, label, value, fallback = UNKNOWN) => {
facts.push({ label, value: valueOr(value, fallback) });
};
const currentDeploymentLabel = (deployment) => {
if (!deployment) return NOT_CONFIGURED;
return [`#${deployment.id || UNKNOWN}`, deployment.status || UNKNOWN, shortSha(deployment.commit_sha)].join(" / ");
};
const channelFacts = (entity = {}) => {
const facts = [];
const frontend = entity.current_deployments?.frontend;
const api = entity.current_deployments?.api;
const branch = entity.branch || entity.mapped_branch || entity.branch_status?.frontend?.branch || publicRouteSlug(entity);
addFact(facts, "Public route", `/${publicRouteSlug(entity)}/{api|frontend}`);
addFact(facts, "Mapped branch", branch);
addFact(facts, "Frontend deployment", currentDeploymentLabel(frontend));
addFact(facts, "API deployment", currentDeploymentLabel(api));
addFact(facts, "Health", entity.readiness || entity.state || entity.status || entity.availability?.status);
addFact(facts, "Last sync", entity.last_sync_at || entity.last_synced_at || entity.operation?.completed_at, NOT_CHECKED);
addFact(facts, "Active blockers", entity.active_blockers ?? entity.issues?.length ?? entity.blockers?.length ?? 0);
return facts;
};
const branchFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Repository", entity.repository || entity.full_name);
addFact(facts, "Branch", entity.branch || entity.name);
addFact(facts, "Existence", entity.exists === false ? "missing" : entity.state || entity.status || "available");
addFact(facts, "Latest commit", shortSha(entity.latest_commit_sha || entity.commit_sha || entity.commit?.sha));
addFact(facts, "Commit date", entity.commit_date || entity.latest_commit_at || entity.commit?.date, UNKNOWN);
addFact(facts, "Protected", boolValue(entity.protected));
addFact(facts, "Last sync", entity.last_sync_result || entity.last_webhook_result || entity.last_checked_at, NOT_CHECKED);
return facts;
};
const deploymentFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Channel", entity.channel_slug || entity.channel_name);
addFact(facts, "App", entity.app || entity.deployment_kind);
addFact(facts, "Commit", shortSha(entity.commit_sha));
addFact(facts, "Status", entity.status);
addFact(facts, "Target", entity.coolify_service_uuid || entity.target_id || entity.service_set_id, NOT_CONFIGURED);
addFact(facts, "Started", entity.started_at || entity.created_at, UNKNOWN);
addFact(facts, "Finished", entity.completed_at || entity.updated_at, entity.status === "running" ? "running" : UNKNOWN);
addFact(facts, "Duration", entity.duration || readableDuration(entity.started_at || entity.created_at, entity.completed_at || entity.updated_at));
addFact(facts, "Current state", entity.active_current || entity.current ? "current" : entity.status === "superseded" ? "superseded" : "history");
return facts;
};
const endpointFacts = (entity = {}) => {
const facts = [];
const url = entity.url || entity.public_url || entity.endpoint?.url || entity.endpoint?.display || entity.endpoint;
addFact(facts, "Public URL", url);
addFact(facts, "Route prefix", entity.route_prefix || entity.prefix || (url ? `/${String(url).split("/").slice(3, 5).join("/")}` : ""));
addFact(facts, "Target", entity.app || entity.service || entity.target || entity.target_service, UNKNOWN);
addFact(facts, "Last probe", entity.probe_status || entity.health_status || entity.status, NOT_CHECKED);
addFact(facts, "Response time", entity.response_time_ms ? `${entity.response_time_ms}ms` : entity.latency_ms ? `${entity.latency_ms}ms` : "", UNKNOWN);
addFact(facts, "TLS/route", entity.tls_warning || entity.route_warning || entity.warning || "ok");
return facts;
};
const coolifyFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Instance", entity.instance_label || entity.instance || entity.instance_id);
addFact(facts, "Project/App", entity.project_uuid || entity.project_id || entity.app_id || entity.resource_uuid, NOT_CONFIGURED);
addFact(facts, "Environment", entity.environment || entity.environment_name || entity.coolify_environment_name, UNKNOWN);
addFact(facts, "Target host", endpointHost(entity), UNKNOWN);
addFact(facts, "Status", entity.status || entity.deployment_status || entity.availability_state || (entity.integrated ? "integrated" : ""));
addFact(facts, "Last deploy", entity.last_deployed_at || entity.last_reconciled_at || entity.updated_at, NOT_CHECKED);
addFact(facts, "Exposed URL", entity.public_url || entity.coolify_public_url || entity.endpoint?.url || entity.endpoint?.display, NOT_CONFIGURED);
addFact(facts, "Health check", entity.health_status || entity.health_check_result || entity.last_reconcile_status, NOT_CHECKED);
return facts;
};
const hostFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Node", entity.node || entity.node_name || entity.label || entity.host_label);
addFact(facts, "Hostname/IP", endpointHost(entity));
addFact(facts, "Provider/location", entity.provider || entity.location || entity.region, UNKNOWN);
addFact(facts, "SSH", boolValue(entity.ssh_available ?? entity.ssh_enabled ?? entity.ssh));
addFact(facts, "Services", entity.service_count ?? entity.services?.length, UNKNOWN);
addFact(facts, "Last health check", lastCheck(entity), NOT_CHECKED);
return facts;
};
const dataServiceFacts = (entity = {}) => {
const target = entity.target || entity;
const replication = entity.replication || target.replication || {};
const facts = [];
addFact(facts, "Service", entity.kind || target.kind || replication.kind);
addFact(facts, "Mode", entity.mode || target.mode || entity.replication_policy?.mode);
addFact(facts, "Node", entity.node || entity.node_name || target.instance_label || replication.label, UNKNOWN);
addFact(facts, "Online state", entity.online === true ? "online" : entity.online === false ? "offline" : entity.online_state || entity.state || target.availability_state || target.deployment_status || replication.status);
addFact(facts, "Hostname", endpointHost({ ...entity, target, replication }), NOT_CONFIGURED);
addFact(facts, "Port", endpointPort({ ...entity, target, replication }), NOT_CONFIGURED);
addFact(facts, "Uptime", uptimeText({ ...entity, ...target, ...replication }));
addFact(facts, "Version", entity.version || target.version || replication.version, UNKNOWN);
addFact(facts, "Replication role", entity.replication_role || replication.role || entity.role, UNKNOWN);
addFact(facts, "Replication lag", entity.replication_lag || replication.lag || replication.lag_seconds, UNKNOWN);
addFact(facts, "Last check", lastCheck({ ...entity, target, replication }), NOT_CHECKED);
return facts;
};
const failoverFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Primary node", entity.primary_node || entity.primary_host || entity.primary?.host, UNKNOWN);
addFact(facts, "Replica node", entity.replica_node || entity.replica_host || entity.replica?.host, UNKNOWN);
addFact(facts, "Readiness", entity.readiness || entity.status || (entity.integrated ? "ready" : "not configured"));
addFact(facts, "Blockers", entity.blockers?.length ?? entity.issues?.length ?? 0);
addFact(facts, "Last failover", entity.last_failover_at || entity.last_checked_at, NOT_CHECKED);
addFact(facts, "Recovery action", entity.recovery_action || entity.next_action || entity.change_control, UNKNOWN);
return facts;
};
const operationFacts = (entity = {}) => {
const currentStep =
entity.current_step ||
(Array.isArray(entity.steps) ? entity.steps.find((step) => ["running", "failed"].includes(step.status)) : null);
const failedStep =
entity.failed_step ||
(Array.isArray(entity.steps) ? entity.steps.find((step) => String(step.status || "") === "failed") : null);
const facts = [];
addFact(facts, "Status", entity.status);
addFact(facts, "Current step", currentStep?.label || currentStep?.step_key, UNKNOWN);
addFact(facts, "Failed step", failedStep?.label || failedStep?.step_key, entity.status === "failed" ? UNKNOWN : "none");
addFact(facts, "Diagnostic", failedStep?.diagnostic || entity.diagnostic, UNKNOWN);
addFact(facts, "Solution", failedStep?.solution_hint || entity.solution_hint, UNKNOWN);
addFact(facts, "Started", entity.started_at || entity.created_at, UNKNOWN);
addFact(facts, "Duration", entity.duration || readableDuration(entity.started_at || entity.created_at, entity.completed_at || entity.updated_at));
return facts;
};
const genericFacts = (entity = {}, detail = "") => {
const facts = [];
addFact(facts, "Type", entity.type || entity.kind || UNKNOWN);
addFact(facts, "Status", entity.status || entity.state || entity.availability_state, UNKNOWN);
addFact(facts, "Host", endpointHost(entity), UNKNOWN);
addFact(facts, "Port", endpointPort(entity), UNKNOWN);
addFact(facts, "Last check", lastCheck(entity), NOT_CHECKED);
if (!isBlank(detail)) addFact(facts, "Detail", detail);
return facts;
};
export const releaseEntityFacts = ({ type = "entity", entity = {}, detail = "" } = {}) => {
const normalized = String(type || "").toLowerCase();
const enriched = { ...(entity || {}), ...(entity?.entity_facts || {}) };
if (normalized.includes("channel")) return channelFacts(enriched);
if (normalized.includes("branch") || normalized.includes("repo") || normalized.includes("source")) return branchFacts(enriched);
if (normalized.includes("deployment")) return deploymentFacts(enriched);
if (normalized.includes("endpoint")) return endpointFacts(enriched);
if (normalized.includes("coolify")) return coolifyFacts(enriched);
if (normalized.includes("host") || normalized.includes("location")) return hostFacts(enriched);
if (normalized.includes("data") || ["database", "redis", "minio"].some((kind) => normalized.includes(kind))) return dataServiceFacts(enriched);
if (normalized.includes("failover")) return failoverFacts(enriched);
if (normalized.includes("operation") || normalized.includes("test")) return operationFacts(enriched);
return genericFacts(enriched, detail);
};
export const releaseEntityTooltipText = (facts = []) =>
facts.map((fact) => `${fact.label}: ${valueOr(fact.value)}`).join("\n");
export const releaseEntityDisplayLabel = (label, entity = {}) =>
label || entity.label || entity.name || entity.slug || entity.resource_name || entity.resource_uuid || "--";
@@ -0,0 +1,19 @@
import { ref } from "vue";
export function useReleaseEntityDrawer() {
const selectedReleaseEntity = ref(null);
const openReleaseEntity = (payload) => {
selectedReleaseEntity.value = payload;
};
const closeReleaseEntity = () => {
selectedReleaseEntity.value = null;
};
return {
selectedReleaseEntity,
openReleaseEntity,
closeReleaseEntity,
};
}
@@ -0,0 +1,11 @@
import { onBeforeUnmount, onMounted } from "vue";
export function useReleaseKeyboardShortcuts(handler) {
onMounted(() => {
window.addEventListener("keydown", handler);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handler);
});
}
@@ -0,0 +1,93 @@
import { computed } from "vue";
export const RELEASE_MANAGER_PANELS = [
"overview",
"channels",
"deployments",
"operations",
"integrations",
"data-services",
"assignments",
"settings",
];
export const RELEASE_MANAGER_PANEL_ALIASES = {
bundles: "deployments",
replay: "operations",
coolify: "integrations",
failover: "data-services",
"data": "data-services",
};
export const routeSlugForReleaseChannel = (channel) => {
const slug = String(channel?.slug || channel?.channel_slug || "").trim().toLowerCase();
return slug === "stable" ? "master" : slug;
};
export const normalizeReleasePanel = (panel) => {
const normalized = String(panel || "overview").trim().toLowerCase();
const mapped = RELEASE_MANAGER_PANEL_ALIASES[normalized] || normalized;
return RELEASE_MANAGER_PANELS.includes(mapped) ? mapped : "overview";
};
export const defaultBranchForReleaseChannel = (channel) =>
routeSlugForReleaseChannel(channel) || String(channel?.branch || "").trim() || "master";
export function useReleaseManagerContext({ route, router, channels, branchSuggestions }) {
const selectedChannel = computed(() => {
const selectedSlug = String(route.query.channel || "").trim().toLowerCase();
return (
channels.value.find((channel) => String(channel.slug || channel.channel_slug).toLowerCase() === selectedSlug) ||
channels.value.find((channel) => channel.default_channel) ||
channels.value[0] ||
null
);
});
const selectedChannelSlug = computed(
() => String(route.query.channel || selectedChannel.value?.slug || selectedChannel.value?.channel_slug || "").trim()
);
const selectedApp = computed(() => {
const app = String(route.query.app || "all").trim().toLowerCase();
return ["all", "frontend", "api"].includes(app) ? app : "all";
});
const selectedBranch = computed(() =>
String(route.query.branch || defaultBranchForReleaseChannel(selectedChannel.value)).trim()
);
const branchOptions = computed(() => {
const values = new Set([
"master",
"beta",
"canary",
"internal",
defaultBranchForReleaseChannel(selectedChannel.value),
...(Array.isArray(branchSuggestions.value) ? branchSuggestions.value : []),
]);
return Array.from(values).filter(Boolean);
});
const updateContext = (patch = {}) => {
const query = {
...route.query,
...patch,
};
for (const key of ["channel", "app", "branch"]) {
if (query[key] === "" || query[key] === null || query[key] === undefined) {
delete query[key];
}
}
router.replace({ path: route.path, query });
};
return {
selectedChannel,
selectedChannelSlug,
selectedApp,
selectedBranch,
branchOptions,
updateContext,
};
}
@@ -1,7 +1,7 @@
<script>
import axios from 'axios'
import {API_URL} from "@/config.js";
import { enqueueRequest } from "@/services/requestQueue.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
/**
* Get the selected customer number for X-Customer-Number header (used by subusers)
@@ -19,7 +19,9 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
}
// Build headers
const headers = {};
const headers = {
...buildCurrentReleaseHeaders(),
};
if (token && token.length > 0) {
headers.Authorization = `Bearer ${token}`;
}
@@ -31,17 +33,18 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
headers['X-Customer-Number'] = selectedCustomerNumber;
}
const requestUrl = resolveReleaseApiUrl(url);
return enqueueRequest(
() => axios({
method,
url: API_URL + url,
url: requestUrl,
...(method === 'GET' ? { params: data } : { data }),
__skipRequestQueue: true,
headers
}),
{
method,
url: API_URL + url,
url: requestUrl,
requestData: {
params: method === 'GET' ? data : null,
data: method === 'GET' ? null : data,
@@ -68,8 +71,9 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
export const unauthenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
return axios({
method,
url: API_URL + url,
data
url: resolveReleaseApiUrl(url),
data,
headers: buildCurrentReleaseHeaders(),
}).catch((error) => {
if (catchCallable) {
// Call the catch callable
@@ -93,6 +97,7 @@ export const paginatedGetRequest = (url, currentPage, itemsPerPage) => {
// Build headers
const headers = {
...buildCurrentReleaseHeaders(),
Authorization: `Bearer ${token}`
};
@@ -103,7 +108,7 @@ export const paginatedGetRequest = (url, currentPage, itemsPerPage) => {
headers['X-Customer-Number'] = selectedCustomerNumber;
}
return axios.get(API_URL + url, {
return axios.get(resolveReleaseApiUrl(url), {
params: {
page: currentPage,
limit: itemsPerPage
+302 -149
View File
@@ -48,7 +48,16 @@ import Swal from "sweetalert2";
import { SubuserGrants } from "@/components/session/token/SessionUser/Objects/SubuserGrants.vue";
import { Subusers } from "@/components/session/token/SessionUser/Objects/Subusers.vue";
import { configureReleaseRuntime } from "@/services/releaseTimeline.js";
import { setReleaseChannelSwitchNoticePrincipal } from "@/services/releaseChannelAvailability.js";
import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js";
import { pingApiServer } from "@/services/apiHealth.js";
import { normalizeSessionPayload } from "@/services/sessionPayload.js";
import {
isReleaseChannelApiAvailabilityError,
markReleaseChannelApiUnavailable,
reconcileSelectedReleaseChannel,
releaseChannelRuntimeRequestParams,
setReleaseChannelSwitchNoticePrincipal,
} from "@/services/releaseChannelAvailability.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -89,19 +98,37 @@ const hydrateSessionFromStorage = () => {
return true;
};
const clearStoredSession = () => {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.removeItem("token");
window.localStorage.removeItem("is_subuser");
} catch {
// Storage can be unavailable in test or private browsing contexts.
}
};
const applyReleaseRuntimeConfig = (runtime) => {
const normalizedRuntime = runtime || {};
configureReleaseRuntime(normalizedRuntime);
reconcileSelectedReleaseChannel(normalizedRuntime);
const runtimeUrls = normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
SessionUser.runtimeConfig.release.traceId.value = normalizedRuntime.trace_id || null;
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
SessionUser.runtimeConfig.release.versions.value = normalizedRuntime.versions || { frontend: null, api: null };
SessionUser.runtimeConfig.release.availableChannels.value =
normalizedRuntime.available_channels || normalizedRuntime.availableChannels || [];
SessionUser.runtimeConfig.release.versions.value = normalizedRuntime.versions || {
frontend: null,
api: null,
bundle_id: null,
};
SessionUser.runtimeConfig.release.frontendBaseUrl.value =
normalizedRuntime.frontend_base_url ||
normalizedRuntime.frontendBaseUrl ||
normalizedRuntime?.channel?.frontend_base_url ||
null;
normalizedRuntime.frontend_base_url || runtimeUrls.frontend_base_url || null;
SessionUser.runtimeConfig.release.apiBaseUrl.value =
normalizedRuntime.api_base_url || normalizedRuntime.apiBaseUrl || normalizedRuntime?.channel?.api_base_url || null;
normalizedRuntime.api_base_url || runtimeUrls.api_base_url || null;
SessionUser.runtimeConfig.release.availability.value = normalizedRuntime.availability || {
configured: true,
missing: [],
@@ -116,26 +143,224 @@ const applyReleaseRuntimeConfig = (runtime) => {
};
};
export const refreshReleaseRuntime = async () => {
return authenticatedRequest("/release/runtime", "GET")
.then((response) => {
applyReleaseRuntimeConfig(response?.data?.data || response?.data || {});
})
.catch((error) => {
console.warn("Could not refresh release runtime", error);
});
export const refreshReleaseRuntime = async ({ throwOnError = false } = {}) => {
try {
const runtime = await fetchReleaseRuntime();
applyReleaseRuntimeConfig(runtime || {});
return runtime || {};
} catch (error) {
console.warn("Could not refresh release runtime", error);
if (throwOnError) {
throw error;
}
return null;
}
};
const resetSessionState = () => {
SessionUser.user.id.value = null;
SessionUser.user.customer_number.value = null;
SessionUser.user.display_name.value = null;
SessionUser.user.group_id.value = null;
SessionUser.user.email.value = null;
SessionUser.user.phone.number.value = null;
SessionUser.user.phone.country_code.value = null;
SessionUser.user.notifications.wash_certificate_email.value = null;
SessionUser.user.notifications.email_notifications_enabled.value = null;
SessionUser.user.notifications.sms_notifications_enabled.value = null;
SessionUser.user.created_at.value = null;
SessionUser.user.updated_at.value = null;
SessionUser.user.cached_at.value = null;
SessionUser.permissions.value = [];
SessionUser.token.value = null;
SessionUser.authenticated.value = false;
SessionUser.isSubuser.value = false;
SessionUser.initiated.value = false;
SessionUser.subuser.id.value = null;
SessionUser.subuser.username.value = null;
SessionUser.subuser.name.value = null;
SessionUser.subuser.email.value = null;
SessionUser.subuser.phone.country_code.value = null;
SessionUser.subuser.phone.number.value = null;
SessionUser.subuser.grants.value = [];
SessionUser.subuser.created_at.value = null;
SessionUser.subuser.updated_at.value = null;
SessionUser.subuser.suspended_at.value = null;
SessionUser.subuser.cached_at.value = null;
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem("selected_customer_number");
} catch {
// Storage is optional for the in-memory reset.
}
}
SessionUser.economicData.customerNumber.value = null;
SessionUser.economicData.name.value = null;
SessionUser.economicData.address.value = null;
SessionUser.economicData.zip.value = null;
SessionUser.economicData.city.value = null;
SessionUser.economicData.mobilePhone.value = null;
SessionUser.economicData.email.value = null;
SessionUser.economicData.cvr.value = null;
SessionUser.economicData.currency.value = null;
SessionUser.economicData.country.value = null;
SessionUser.economicData.cached_at.value = null;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
SessionUser.runtimeConfig.release.traceId.value = null;
SessionUser.runtimeConfig.release.channel.value = null;
SessionUser.runtimeConfig.release.availableChannels.value = [];
SessionUser.runtimeConfig.release.versions.value = { frontend: null, api: null, bundle_id: null };
SessionUser.runtimeConfig.release.frontendBaseUrl.value = null;
SessionUser.runtimeConfig.release.apiBaseUrl.value = null;
SessionUser.runtimeConfig.release.availability.value = {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
SessionUser.runtimeConfig.release.capturePolicy.value = {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
};
setReleaseChannelSwitchNoticePrincipal("");
configureReleaseRuntime({});
};
let sessionBootstrapPromise = null;
const extractErrorMessage = (error) => {
if (typeof error === "string") {
return error;
}
if (error?.response?.data?.data?.message) {
return error.response.data.data.message;
}
if (error?.response?.data?.message) {
return error.response.data.message;
}
if (typeof error?.response?.data === "string") {
return error.response.data;
}
if (error?.message) {
return error.message;
}
return "";
};
export const isInvalidSessionError = (error) => {
const status = Number.parseInt(String(error?.response?.status ?? ""), 10);
const message = extractErrorMessage(error).toLowerCase();
if (status === 401 || status === 419) {
return true;
}
if (![400, 403].includes(status)) {
return false;
}
return (
message.includes("invalid session") ||
message.includes("invalid token") ||
message.includes("unauthenticated") ||
message.includes("unauthorized")
);
};
const redirectToConnectivityIssue = () => {
if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") {
return false;
}
window.location.href = "/connectivity-issue";
return true;
};
export const handleSessionBootstrapFailure = async (error, { title, text } = {}) => {
if (isInvalidSessionError(error)) {
const pingResult = await pingApiServer();
if (!pingResult.ok) {
console.warn("Preserving stored session because API ping failed during invalid-session handling.", pingResult);
const redirected = redirectToConnectivityIssue();
return {
cleared: false,
apiReachable: false,
ping: pingResult,
redirectedTo: redirected ? "/connectivity-issue" : null,
};
}
forceClearSession();
SessionUser.functions.redirectTo.pages.login();
return { cleared: true, apiReachable: true, ping: pingResult, redirectedTo: "/login" };
}
parseError(error, "auth");
console.error(error);
await Swal.fire({
title,
text,
icon: "error",
confirmButtonText: "Ok",
});
return { cleared: false, apiReachable: null, ping: null, redirectedTo: null };
};
export const refreshSessionData = async () => {
if (!hydrateSessionFromStorage()) {
resetSessionState();
return null;
}
if (SessionUser.isSubuser.value) {
return await getSubuserSessionData();
}
return await getSessionData();
};
/**
* Initiate the user session on app start
* @returns {Promise<void>}
*/
export const initiateOnAppStart = async () => {
if (hydrateSessionFromStorage()) {
export const initiateOnAppStart = async ({ force = false } = {}) => {
if (sessionBootstrapPromise && !force) {
return await sessionBootstrapPromise;
}
const bootstrapPromise = (async () => {
if (!hydrateSessionFromStorage()) {
return null;
}
if (SessionUser.isSubuser.value) {
await getSubuserSessionData();
} else {
await getSessionData();
return await getSubuserSessionData();
}
return await getSessionData();
})();
sessionBootstrapPromise = bootstrapPromise;
try {
return await bootstrapPromise;
} finally {
if (sessionBootstrapPromise === bootstrapPromise) {
sessionBootstrapPromise = null;
}
}
};
@@ -176,7 +401,7 @@ export const authenticateUser = async (customer_number, password) => {
* @param {number} [credentials.phone] - Phone number (4-15 digits)
* @param {number} [credentials.subuser_id] - Subuser ID
* @param {string} [credentials.username] - Username
* @param {string} credentials.password - Password (8-255 characters)
* @param {string} credentials.password - Password (8-255 characters, uppercase, lowercase, and number)
* @returns {Promise<void>}
*/
export const authenticateSubuser = async (credentials) => {
@@ -252,17 +477,16 @@ export const getSubuserSessionData = async () => {
await refreshReleaseRuntime();
SessionUser.initiated.value = true;
})
.catch((error) => {
parseError(error, "auth");
console.error(error);
Swal.fire({
.catch(async (error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
return await handleSessionBootstrapFailure(error, {
title: "Fejl ved hentning af subbrugerdata",
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
icon: "error",
confirmButtonText: "Ok",
}).then(() => {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login();
});
});
};
@@ -272,67 +496,66 @@ export const getSubuserSessionData = async () => {
* @returns {Promise<void>}
*/
export const getSessionData = async () => {
return await authenticatedRequest("/auth/session", "GET")
return await authenticatedRequest("/auth/session", "GET", releaseChannelRuntimeRequestParams())
.then((response) => {
SessionUser.user.id.value = response.data.data.id;
SessionUser.user.customer_number.value = response.data.data.customer_number;
SessionUser.user.group_id.value = response.data.data.group_id;
SessionUser.user.email.value = response.data.data.email;
SessionUser.user.phone.number.value = response.data.data.phone.number;
SessionUser.user.phone.country_code.value = response.data.data.phone.country_code;
SessionUser.user.notifications.wash_certificate_email.value =
response.data.data.notifications.wash_certificate_email;
const session = normalizeSessionPayload(response?.data?.data);
SessionUser.user.id.value = session.id;
SessionUser.user.customer_number.value = session.customer_number;
SessionUser.user.group_id.value = session.group_id;
SessionUser.user.email.value = session.email;
SessionUser.user.phone.number.value = session.phone.number;
SessionUser.user.phone.country_code.value = session.phone.country_code;
SessionUser.user.notifications.wash_certificate_email.value = session.notifications.wash_certificate_email;
SessionUser.user.notifications.email_notifications_enabled.value =
response.data.data.notifications.email_notifications_enabled;
SessionUser.user.notifications.sms_notifications_enabled.value =
response.data.data.notifications.sms_notifications_enabled;
SessionUser.user.created_at.value = response.data.data.created_at;
SessionUser.user.updated_at.value = response.data.data.updated_at;
SessionUser.user.display_name.value = response.data.data.display_name;
session.notifications.email_notifications_enabled;
SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled;
SessionUser.user.created_at.value = session.created_at;
SessionUser.user.updated_at.value = session.updated_at;
SessionUser.user.display_name.value = session.display_name;
setReleaseChannelSwitchNoticePrincipal(
`user:${response.data.data.id || response.data.data.customer_number || response.data.data.email || "unknown"}`
`user:${session.id || session.customer_number || session.email || "unknown"}`
);
// Set the last cached time to now, this is used to determine if the user's data is outdated
SessionUser.user.cached_at.value = new Date();
SessionUser.permissions.value = response.data.data.permissions;
SessionUser.permissions.value = session.permissions;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = normalizePositiveInteger(
response?.data?.data?.runtime_config?.economic?.transaction_draft_customer_number
session.runtime_config.economic.transaction_draft_customer_number
);
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = normalizePositiveInteger(
response?.data?.data?.runtime_config?.economic?.default_distribution_department_id
session.runtime_config.economic.default_distribution_department_id
);
applyReleaseRuntimeConfig(response?.data?.data?.runtime_config?.release || {});
applyReleaseRuntimeConfig(session.runtime_config.release);
// E-conomic data is only fetched if the array isn't empty
if (response.data.data.economic_customer.length > 0) {
SessionUser.economicData.customerNumber.value = response.data.data.economic_customer.customerNumber;
SessionUser.economicData.name.value = response.data.data.economic_customer.name;
if (session.economic_customer) {
SessionUser.economicData.customerNumber.value = session.economic_customer.customerNumber;
SessionUser.economicData.name.value = session.economic_customer.name;
// Set the display name to the e-conomic name if it's empty
if (SessionUser.user.display_name.value === null || SessionUser.user.display_name.value === "Unnamed") {
SessionUser.user.display_name.value = response.data.data.economic_customer.name;
SessionUser.user.display_name.value = session.economic_customer.name;
}
SessionUser.economicData.address.value = response.data.data.economic_customer.address;
SessionUser.economicData.zip.value = response.data.data.economic_customer.zip;
SessionUser.economicData.city.value = response.data.data.economic_customer.city;
SessionUser.economicData.mobilePhone.value = response.data.data.economic_customer.mobilePhone;
SessionUser.economicData.email.value = response.data.data.economic_customer.email;
SessionUser.economicData.cvr.value = response.data.data.economic_customer.corporateIdentificationNumber;
SessionUser.economicData.currency.value = response.data.data.economic_customer.currency;
SessionUser.economicData.country.value = response.data.data.economic_customer.country;
SessionUser.economicData.address.value = session.economic_customer.address;
SessionUser.economicData.zip.value = session.economic_customer.zip;
SessionUser.economicData.city.value = session.economic_customer.city;
SessionUser.economicData.mobilePhone.value = session.economic_customer.mobilePhone;
SessionUser.economicData.email.value = session.economic_customer.email;
SessionUser.economicData.cvr.value = session.economic_customer.corporateIdentificationNumber;
SessionUser.economicData.currency.value = session.economic_customer.currency;
SessionUser.economicData.country.value = session.economic_customer.country;
SessionUser.economicData.cached_at.value = new Date();
}
SessionUser.initiated.value = true;
})
.catch((error) => {
parseError(error, "auth");
console.error(error);
Swal.fire({
.catch(async (error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
return await handleSessionBootstrapFailure(error, {
title: "Fejl ved hentning af brugerdata",
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
icon: "error",
confirmButtonText: "Ok",
}).then(() => {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login();
});
});
};
@@ -343,22 +566,13 @@ export const getSessionData = async () => {
*/
export const destroySession = async () => {
return await authenticatedRequest("/auth/logout", "GET")
.then(() => {
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
})
.catch((error) => {
parseError(error, "auth");
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
console.error(error);
})
.finally(() => {
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
clearStoredSession();
resetSessionState();
});
};
@@ -366,9 +580,8 @@ export const destroySession = async () => {
* Force sign out the user
*/
export const forceClearSession = () => {
localStorage.removeItem("token");
localStorage.removeItem("is_subuser");
SessionUser.forceRefresh();
clearStoredSession();
resetSessionState();
};
/**
@@ -487,6 +700,7 @@ export const SessionUser = {
release: {
traceId: ref(null),
channel: ref(null),
availableChannels: ref([]),
versions: ref({ frontend: null, api: null }),
frontendBaseUrl: ref(null),
apiBaseUrl: ref(null),
@@ -697,69 +911,7 @@ export const SessionUser = {
},
/** Reset the user's session data (Cache) */
forceRefresh: () => {
SessionUser.user.id.value = null;
SessionUser.user.customer_number.value = null;
SessionUser.user.display_name.value = null;
SessionUser.user.group_id.value = null;
SessionUser.user.email.value = null;
SessionUser.user.phone.number.value = null;
SessionUser.user.phone.country_code.value = null;
SessionUser.user.notifications.wash_certificate_email.value = null;
SessionUser.user.notifications.email_notifications_enabled.value = null;
SessionUser.user.notifications.sms_notifications_enabled.value = null;
SessionUser.user.created_at.value = null;
SessionUser.user.updated_at.value = null;
SessionUser.user.cached_at.value = null;
SessionUser.permissions.value = [];
SessionUser.authenticated.value = false;
SessionUser.isSubuser.value = false;
// Clear subuser data
SessionUser.subuser.id.value = null;
SessionUser.subuser.username.value = null;
SessionUser.subuser.name.value = null;
SessionUser.subuser.email.value = null;
SessionUser.subuser.phone.country_code.value = null;
SessionUser.subuser.phone.number.value = null;
SessionUser.subuser.grants.value = [];
SessionUser.subuser.created_at.value = null;
SessionUser.subuser.updated_at.value = null;
SessionUser.subuser.suspended_at.value = null;
SessionUser.subuser.cached_at.value = null;
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
localStorage.removeItem("selected_customer_number");
// Clear economic data
SessionUser.economicData.customerNumber.value = null;
SessionUser.economicData.name.value = null;
SessionUser.economicData.address.value = null;
SessionUser.economicData.zip.value = null;
SessionUser.economicData.city.value = null;
SessionUser.economicData.mobilePhone.value = null;
SessionUser.economicData.email.value = null;
SessionUser.economicData.cvr.value = null;
SessionUser.economicData.currency.value = null;
SessionUser.economicData.country.value = null;
SessionUser.economicData.cached_at.value = null;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
SessionUser.runtimeConfig.release.traceId.value = null;
SessionUser.runtimeConfig.release.channel.value = null;
SessionUser.runtimeConfig.release.versions.value = { frontend: null, api: null };
SessionUser.runtimeConfig.release.frontendBaseUrl.value = null;
SessionUser.runtimeConfig.release.apiBaseUrl.value = null;
SessionUser.runtimeConfig.release.availability.value = {
configured: true,
missing: [],
status: "ready",
explicit: false,
};
SessionUser.runtimeConfig.release.capturePolicy.value = {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
};
setReleaseChannelSwitchNoticePrincipal("");
getSessionData();
resetSessionState();
},
auth: {
/** Authenticate the user */
@@ -768,7 +920,7 @@ export const SessionUser = {
authenticateSubuser: authenticateSubuser,
/** Logout the user */
logout: destroySession,
/** Force clear the user's session WARNING: This will not destroy the user's token */
/** Force clear the user's local session state and stored token */
forceClearSession: forceClearSession,
/** reCAPTCHA */
reCAPTCHA: reCAPTCHA,
@@ -1136,6 +1288,7 @@ export const SessionUser = {
editField: EditFieldForm,
getSessionData: getSessionData,
getSubuserSessionData: getSubuserSessionData,
refreshSessionData: refreshSessionData,
refreshReleaseRuntime: refreshReleaseRuntime,
initiateOnAppStart: initiateOnAppStart,
checkForUpdates() {
@@ -49,7 +49,7 @@ const buildDepartmentOptions = (departments, selectedDepartmentId) => {
const buildComplaintCategoryOptions = (selectedCategory) => {
const normalizedSelectedCategory = String(selectedCategory ?? "").trim();
const options = [
`<option value="">${escapeHtml("Vaelg kategori")}</option>`,
`<option value="">${escapeHtml("Vælg kategori")}</option>`,
...DEPARTMENT_DAILY_REPORT_COMPLAINT_CATEGORY_OPTIONS.map((category) => {
const selected = category.value === normalizedSelectedCategory ? " selected" : "";
return `<option value="${escapeHtml(category.value)}"${selected}>${escapeHtml(category.label)}</option>`;
@@ -236,7 +236,7 @@ const resolveComplaintCustomerNumberFromLookup = (lookupState) => {
return lookupState.selectedCustomer.customer_number;
}
throw new Error("Vaelg en kunde fra listen eller ryd feltet.");
throw new Error("Vælg en kunde fra listen eller ryd feltet.");
};
const setupComplaintCustomerLookupField = (lookupState) => {
@@ -521,28 +521,28 @@ export const DepartmentDailyReportComplaints = {
10
);
if (!Number.isFinite(departmentId) || departmentId <= 0) {
throw new Error("Afdeling er paakraevet.");
throw new Error("Afdeling er påkrævet.");
}
const washDate = (
document.getElementById("superuser-complaint-wash-date")?.value || ""
).trim();
if (washDate === "") {
throw new Error("Dato for vask er paakraevet.");
throw new Error("Dato for vask er påkrævet.");
}
const category = (
document.getElementById("superuser-complaint-category")?.value || ""
).trim();
if (!isDepartmentDailyReportComplaintCategory(category)) {
throw new Error("Kategori er paakraevet.");
throw new Error("Kategori er påkrævet.");
}
const description = (
document.getElementById("superuser-complaint-description")?.value || ""
).trim();
if (description === "") {
throw new Error("Beskrivelse er paakraevet.");
throw new Error("Beskrivelse er påkrævet.");
}
const customerNumber = resolveComplaintCustomerNumberFromLookup(customerLookupState);
@@ -20,6 +20,67 @@
return normalizedRelayId;
};
const OPERATIONAL_STATUSES = ["AVAILABLE", "OCCUPIED", "RESERVED"];
const SELF_SERVE_CONFIGURATION_FIELDS = [
{ field: "relay_in_id", label: "Indgangsrelæ" },
{ field: "relay_out_id", label: "Udgangsrelæ" },
{ field: "relay_machine_id", label: "Maskinrelæ" },
{ field: "relay_machine_program_picker_id", label: "Programvælgerrelæ" },
{ field: "relay_machine_cleaner_id", label: "Vaskerelæ" },
{ field: "dynamic_image_id", label: "Maskinstatusbillede" },
{ field: "machine_type_id", label: "Maskintype" },
];
const hasConfiguredValue = (value) => {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "string") {
const normalizedValue = value.trim().toLowerCase();
return normalizedValue !== "" && normalizedValue !== "0" && normalizedValue !== "null";
}
if (typeof value === "number") {
return value > 0;
}
return Boolean(value);
};
const getSelfServeConfigurationWarnings = (object) => {
if (Array.isArray(object?.dognvask_configuration_warnings)) {
return object.dognvask_configuration_warnings;
}
return SELF_SERVE_CONFIGURATION_FIELDS
.filter(({ field }) => !hasConfiguredValue(object?.[field]))
.map(({ field, label }) => ({
field,
label,
message: `${label} mangler`,
}));
};
const isSelfServeConfigured = (object) => {
if (typeof object?.dognvask_configured === "boolean") {
return object.dognvask_configured;
}
if (typeof object?.selfserve_configured === "boolean") {
return object.selfserve_configured;
}
return getSelfServeConfigurationWarnings(object).length === 0;
};
const isMachineStatusEnabled = (object) => {
if (typeof object?.machine_status_enabled === "boolean") {
return object.machine_status_enabled;
}
return OPERATIONAL_STATUSES.includes(String(object?.status ?? "").toUpperCase());
};
/**
* The Department Lanes object
*/
@@ -276,6 +337,20 @@
},
functions: {
getDepartmentName: getDepartmentName,
getStatusToggles: async (departmentId) => {
return SessionUser.request('/department/lanes/status-toggles', 'GET', {
department_id: parseInt(departmentId)
});
},
setMachineStatusEnabled: async (laneId, enabled) => {
return SessionUser.request('/modules/self-serve/lane/status', 'PUT', {
lane_id: parseInt(laneId),
enabled: Boolean(enabled),
});
},
isMachineStatusEnabled,
isSelfServeConfigured,
getSelfServeConfigurationWarnings,
isOperational: (object) => {
return object.status === "AVAILABLE" || object.status === "OCCUPIED" || object.status === "RESERVED";
},
@@ -9,8 +9,16 @@ const escapeHtml = (value) => String(value ?? "")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const buildInviteFormHtml = (defaults = {}) => `
const buildInviteFormHtml = (defaults = {}, options = {}) => `
<div class="subuser-form">
${options.includeCustomerNumber ? `
<div class="field">
<label class="label">Kundenummer</label>
<div class="control">
<input id="subuser-form-customer-number" class="input" type="number" inputmode="numeric" value="${escapeHtml(defaults.customer_number || defaults.customerNumber || "")}" placeholder="Kundens kundenummer" />
</div>
</div>
` : ""}
<div class="field">
<label class="label">Navn</label>
<div class="control">
@@ -41,11 +49,17 @@ const buildInviteFormHtml = (defaults = {}) => `
</div>
`;
const readInviteFormValues = () => {
const readInviteFormValues = (options = {}) => {
const customerNumberRaw = document.getElementById("subuser-form-customer-number")?.value?.trim() || "";
const name = document.getElementById("subuser-form-name")?.value?.trim() || "";
const phoneCountryCodeRaw = document.getElementById("subuser-form-phone-country-code")?.value?.trim() || "";
const phoneRaw = document.getElementById("subuser-form-phone")?.value?.trim() || "";
if (options.includeCustomerNumber && !/^\d+$/.test(customerNumberRaw)) {
Swal.showValidationMessage("Kundenummer skal udfyldes.");
return null;
}
if (name.length < 3) {
Swal.showValidationMessage("Navn skal være mindst 3 tegn.");
return null;
@@ -62,6 +76,7 @@ const readInviteFormValues = () => {
}
return {
...(options.includeCustomerNumber ? { customer_number: Number.parseInt(customerNumberRaw, 10) } : {}),
name,
phone_country_code: Number.parseInt(phoneCountryCodeRaw, 10),
phone: Number.parseInt(phoneRaw, 10),
@@ -91,17 +106,17 @@ const showInviteFeedback = async (response) => {
});
};
const submitInviteForm = async ({ title, confirmButtonText, defaults = {}, endpoint, method, payloadBuilder }) => {
const submitInviteForm = async ({ title, confirmButtonText, defaults = {}, endpoint, method, payloadBuilder, includeCustomerNumber = false }) => {
const result = await Swal.fire({
title,
html: buildInviteFormHtml(defaults),
html: buildInviteFormHtml(defaults, { includeCustomerNumber }),
width: 640,
focusConfirm: false,
showCancelButton: true,
confirmButtonText,
cancelButtonText: "Annuller",
preConfirm: async () => {
const values = readInviteFormValues();
const values = readInviteFormValues({ includeCustomerNumber });
if (!values) {
return false;
}
@@ -142,13 +157,15 @@ export const Subusers = {
return `${normalized.slice(0, 3).join(", ")} +${normalized.length - 3}`;
},
async showInviteForm(refreshCallback = null) {
async showInviteForm(refreshCallback = null, options = {}) {
const superuser = Boolean(options.superuser);
const response = await submitInviteForm({
title: "Invitér chauffør",
confirmButtonText: "Send invitation",
endpoint: "/subusers/invite",
endpoint: superuser ? "/superuser/subusers/invite" : "/subusers/invite",
method: "POST",
payloadBuilder: (values) => values,
includeCustomerNumber: superuser,
});
if (!response) {
@@ -162,9 +179,14 @@ export const Subusers = {
return response;
},
async resendInvite(subuser, refreshCallback = null) {
async resendInvite(subuser, refreshCallback = null, options = {}) {
const superuser = Boolean(options.superuser);
try {
const response = await authenticatedRequest("/subusers/invite/resend", "POST", { id: subuser.id });
const response = await authenticatedRequest(
superuser ? "/superuser/subusers/invite/resend" : "/subusers/invite/resend",
"POST",
{ id: subuser.id, ...(subuser.grant_id ? { grant_id: subuser.grant_id } : {}) }
);
await showInviteFeedback(response);
if (typeof refreshCallback === "function") {
await refreshCallback();
@@ -1,5 +1,6 @@
export const SELF_SERVE_TASK_BUTTON_RESET = "reset";
export const SELF_SERVE_TASK_BUTTON_START = "start";
export const SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER = "program_picker";
export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
{
@@ -7,6 +8,11 @@ export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
name: "Reset",
description: "Reset button",
},
{
id: SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
name: "Program picker",
description: "Program picker wheel",
},
...Array.from({ length: 12 }, (_, index) => ({
id: index,
name: `Program ${index + 1}`,
@@ -22,6 +28,7 @@ export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
const KNOWN_SPECIAL_BUTTONS = new Set([
SELF_SERVE_TASK_BUTTON_RESET,
SELF_SERVE_TASK_BUTTON_START,
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
]);
const BUTTON_LABELS = new Map(
@@ -1,13 +1,14 @@
<script>
import { ref } from 'vue'
import axios from 'axios'
import {API_URL} from "@/config.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
export const unauthenticatedRequest = (url, method, data) => {
return axios({
method,
url: API_URL + url,
url: resolveReleaseApiUrl(url),
data,
headers: buildCurrentReleaseHeaders(),
});
};
</script>
+85 -27
View File
@@ -507,6 +507,63 @@ export const clearStoredPosOrderId = () => {
localStorage.removeItem("pos_order_id");
};
const hasActivePosOrderContext = () => {
return Boolean(
toPositiveInteger(order_id.value) ||
getSelectedCustomerNumber() ||
String(customer_name.value ?? "").trim() ||
(Array.isArray(order_items.value) && order_items.value.length > 0) ||
String(reference.value ?? "").trim() ||
String(order_notes.value ?? "").trim() ||
String(order_po.value ?? "").trim() ||
String(order_safety_seal.value ?? "").trim() ||
String(reg_1.value ?? "").trim() ||
String(reg_2.value ?? "").trim() ||
String(reg_3.value ?? "").trim() ||
invoiceCollectionId.value ||
completed_at.value ||
getStoredPosOrderId()
);
};
export const clearActivePosOrderContext = (options = {}) => {
const normalizedOptions = {
clearStep: false,
clearStoredOrderId: true,
...options,
};
const hadActiveContext = hasActivePosOrderContext();
clearErrors();
if (normalizedOptions.clearStep) {
step.value = 1;
}
order_id.value = null;
clearSelectedCustomerState({ clearOrderNotes: true });
order_items.value = [];
user_discounts.value = [];
has_user_discounts_loaded.value = false;
scans.value = [];
scan_data.value = [];
invoiceCollectionId.value = null;
completed_at.value = null;
reference.value = "";
order_notes.value = "";
order_po.value = "";
order_safety_seal.value = "";
reg_1.value = "";
reg_2.value = "";
reg_3.value = "";
isCreatingOrder.value = false;
createOrderRequest = null;
clearSelectedOrderBookingSelection();
if (normalizedOptions.clearStoredOrderId) {
clearStoredPosOrderId();
}
return hadActiveContext;
};
const SELECTED_ORDER_BOOKING_ID_STORAGE_KEY = "pos_selected_order_booking_id";
const SELECTED_ORDER_BOOKING_PLATE_STORAGE_KEY = "pos_selected_order_booking_plate";
const SELECTED_ORDER_BOOKING_SKIPPED_PLATE_STORAGE_KEY = "pos_selected_order_booking_skipped_plate";
@@ -692,26 +749,7 @@ export const restoreStoredPosOrderId = async (
export const reset_all_values = () => {
// Reset all values to their initial state
clearErrors();
step.value = 1;
order_id.value = null;
clearCustomerSelection();
clearCache();
user_discounts.value = [];
has_user_discounts_loaded.value = false;
scans.value = [];
scan_data.value = [];
invoiceCollectionId.value = null;
completed_at.value = null;
reference.value = "";
order_notes.value = "";
order_po.value = "";
order_safety_seal.value = "";
isCreatingOrder.value = false;
createOrderRequest = null;
clearSelectedOrderBookingSelection();
// Clear the order id from the local storage (if present)
clearStoredPosOrderId();
clearActivePosOrderContext({ clearStep: true });
// Clear the query parameters
window.history.pushState({}, "", `?step=1`);
};
@@ -859,7 +897,11 @@ export const createOrder = async (options = { isMobile: false }) => {
parseError(response, "stepError");
return false;
}
order_id.value = parseInt(response.data.data.id);
const createdOrder = response.data.data || {};
order_id.value = parseInt(createdOrder.id);
if (!isBlankPosMetadataValue(createdOrder.po)) {
order_po.value = String(createdOrder.po);
}
if (selectedDepartmentId) {
department_id.value = selectedDepartmentId;
}
@@ -1230,6 +1272,20 @@ const getOrderItemNotes = (item) => {
return notesValue === "" ? null : notesValue;
};
const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
const normalizedSourceReference = String(sourceReference ?? "").trim();
if (!normalizedSourceReference || !isBlankPosMetadataValue(reference.value)) {
return;
}
reference.value = normalizedSourceReference;
const normalizedOrderId = toPositiveInteger(order_id.value);
if (normalizedOrderId) {
await SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedSourceReference);
}
};
const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) => {
const productId = getOrderItemProductId(sourceItem);
const quantity = getOrderItemQuantity(sourceItem);
@@ -1258,6 +1314,8 @@ export const copyLastWashItemsToCurrentOrder = async (sourceItems = [], options
return false;
}
await copyLastWashReferenceToEmptyCurrentOrder(options.sourceReference ?? options.sourceOrder?.reference);
const didEnsureOrder = await createOrder({
...options,
isMobile: false,
@@ -2078,11 +2136,6 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
try {
const currentOrderItems = await loadOrderItems();
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
return true;
}
let booking = getSelectedPendingOrderBooking();
if (!booking || !Array.isArray(booking?.items)) {
booking = await SessionUser.objects.order_bookings.get.single(normalizedBookingId, {
@@ -2124,11 +2177,16 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
const bookingPo = String(booking?.po ?? "").trim();
if (bookingPo !== "" && bookingPo !== order_po.value) {
if (bookingPo !== "" && isBlankPosMetadataValue(order_po.value)) {
await SessionUser.objects.orders.set.po(normalizedOrderId, bookingPo);
order_po.value = bookingPo;
}
const currentOrderItems = await loadOrderItems();
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
return true;
}
const bookingItems = Array.isArray(booking?.items) ? booking.items : [];
if (bookingItems.length === 0) {
return true;
@@ -7,7 +7,9 @@ import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelec
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
<template>
@@ -62,13 +64,13 @@ const route = useRoute();
<span>{{ $t('header.return_to_account') }}</span>
</router-link>
</template>
<router-link class="button is-white" to="/user/profile" v-if="SessionUser.authenticated.value">
<router-link class="button is-white" to="/user/profile" v-if="isAuthenticatedSessionReady">
<span class="icon">
<i class="fas fa-user-circle" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ SessionUser.authenticated.value ? SessionUser.getName() : ''}}</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ isAuthenticatedSessionReady ? SessionUser.getName() : ''}}</span>
</router-link>
<template v-else>
<template v-if="!SessionUser.authenticated.value">
<router-link class="button is-white" to="/register">
<span class="icon">
<i class="fas fa-user-plus" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { BIcon, BMenu, BMenuItem, BMenuList, BTooltip } from "buefy";
import type { NavigationItemProps } from "@/components/models/navigation/NavigationItem.vue";
import { useNavigationItems } from "@/components/models/navigation/items/NavigationMenuItems.vue";
@@ -8,8 +9,10 @@ import { ADMIN_DEPARTMENT_SELECTION_CLASS } from "@/components/models/navigation
import { isDepartmentLabelValid, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { isSmall } from "@/components/displays/pagination/PaginationDisplayIsSmall.vue";
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue";
import { requestOpenErrorReport } from "@/services/errorReportLauncher.js";
type NavigationMenuItem = NavigationItemProps & {
priority_order?: number | null;
@@ -18,9 +21,17 @@ type NavigationMenuItem = NavigationItemProps & {
type NavigationBadge = NonNullable<NavigationMenuItem["badge"]>;
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
const { parsedItems, parsedItemsGlobal } = useNavigationItems();
const ERROR_REPORT_BUTTON_FALLBACK = "Report error";
const isMatchingRoute = (item: NavigationMenuItem) => item.to === route.path;
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
const shouldShowErrorReportAction = computed(() => !isSmall.value && isAuthenticated.value);
const errorReportButtonLabel = computed(() => {
const label = t("error_report.button");
return label === "error_report.button" ? ERROR_REPORT_BUTTON_FALLBACK : label;
});
const navigationItems = computed<NavigationMenuItem[]>(() => {
const items = [...parsedItems(), ...parsedItemsGlobal()] as NavigationMenuItem[];
@@ -452,10 +463,40 @@ const getChildBadgeTestId = (item: NavigationMenuItem) => (isDraftsNavigationChi
</BMenuList>
</BMenu>
<LanguageSelector />
<ReleaseChannelSidebarSelector />
<div v-if="shouldShowErrorReportAction" class="desktop-buefy-error-report-action">
<button
class="button is-text desktop-buefy-error-report-button"
type="button"
data-testid="error-report-button"
@click="requestOpenErrorReport"
>
<span class="icon is-small"><i class="fas fa-bug" aria-hidden="true"></i></span>
<span>{{ errorReportButtonLabel }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.desktop-buefy-error-report-action {
padding: 0 16px 14px;
}
.desktop-buefy-error-report-button {
width: 100%;
justify-content: flex-start;
color: #172033;
text-decoration: none;
}
.button.desktop-buefy-error-report-button:hover,
.button.desktop-buefy-error-report-button:focus-visible {
background: rgba(180, 35, 24, 0.08);
color: #b42318;
text-decoration: none;
}
.desktop-buefy-item-label,
.desktop-buefy-child-label {
display: flex;
@@ -13,6 +13,7 @@ const showProfile = computed(() => {
const path = route.path;
return path.startsWith("/user");
});
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
@@ -23,7 +24,7 @@ const showProfile = computed(() => {
<nav class="navbar is-link is-mobile is-fixed-top mobile-header-section" role="navigation" aria-label="main navigation" :class="{'is-blurred': isTransparent}">
<div class="navbar-item" v-if="showProfile">
<!-- Profile -->
<router-link class="button is-white py-0" to="/user/profile" v-if="SessionUser.authenticated.value">
<router-link class="button is-white py-0" to="/user/profile" v-if="isAuthenticatedSessionReady">
<span class="icon is-large">
<i class="fas fa-user-circle fa-2x"
:class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
@@ -45,7 +46,7 @@ const showProfile = computed(() => {
<NavigationMenuDepartment/>
</div>
<!-- Subuser Grant Selection -->
<div class="navbar-item" v-if="SessionUser.isSubuser.value && showProfile">
<div class="navbar-item" v-if="isAuthenticatedSessionReady && SessionUser.isSubuser.value && showProfile">
<SubuserGrantSelector :showLabel="false" :compact="true" />
</div>
<div class="navbar-end" style="margin-left: auto;" v-show="!SessionUser.canAccessAdmin()">
@@ -1,12 +1,31 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { toggleExpanded, isOpen } from '@/components/viewport/page/headers/ViewportHeaderSettings.vue';
import NavigationMenuItems from "@/components/viewport/page/headers/menu/NavigationMenuItems.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import DesktopNavigationBuefy from "@/components/viewport/page/headers/DesktopNavigationBuefy.vue";
import { requestOpenErrorReport } from "@/services/errorReportLauncher.js";
const { t } = useI18n({ useScope: "global" });
const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || 'No build date';
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || 'No commit hash';
const VITE_APP_VERSION = import.meta.env.VITE_APP_VERSION || 'No app version';
const VITE_IS_DEV = import.meta.env.DEV || false;
const ERROR_REPORT_BUTTON_FALLBACK = "Report error";
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
const errorReportButtonLabel = computed(() => {
const label = t("error_report.button");
return label === "error_report.button" ? ERROR_REPORT_BUTTON_FALLBACK : label;
});
const openErrorReport = () => {
if (isOpen.value) {
toggleExpanded();
}
requestOpenErrorReport();
};
/**
* Scrollable menu for mobile viewports.
@@ -65,12 +84,23 @@ const VITE_IS_DEV = import.meta.env.DEV || false;
<NavigationMenuItems />
</div>
<!-- Bottom version info -->
<div class="has-text-centered" style="padding: 16px; font-size: 12px; color: gray;">
<div class="has-text-centered" style="padding: 16px; font-size: 12px; color: gray;" data-testid="mobile-build-info">
<!--<div>Version: {{ VITE_APP_VERSION }}</div>-->
<div>Commit: {{ VITE_COMMIT_HASH }}</div>
<div>Build Date: {{ SessionUser.functions.date.toLocal(VITE_BUILD_DATE) }}</div>
<div v-if="VITE_IS_DEV" style="color: red;">Development Mode</div>
</div>
<div v-if="isAuthenticated" class="mobile-error-report-action">
<button
class="button is-danger mobile-error-report-button"
type="button"
data-testid="error-report-button"
@click="openErrorReport"
>
<span class="icon is-small"><i class="fas fa-bug" aria-hidden="true"></i></span>
<span>{{ errorReportButtonLabel }}</span>
</button>
</div>
</div>
</div>
@@ -111,6 +141,12 @@ const VITE_IS_DEV = import.meta.env.DEV || false;
.mobile-menu-open .mobile-menu-spacer {
flex: 1;
}
.mobile-error-report-action {
padding: 0 16px 16px;
}
.mobile-error-report-button {
width: 100%;
}
.mobile-menu-open .text {
/* Menu */
margin: 0 auto;
@@ -144,4 +180,4 @@ const VITE_IS_DEV = import.meta.env.DEV || false;
/* Prevent click-through */
pointer-events: auto;
}
</style>
</style>
@@ -6,7 +6,9 @@ import CollectedInvoiceQueueMonitor from "@/components/viewport/page/headers/men
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
<template>
@@ -62,13 +64,13 @@ const route = useRoute();
<span>{{ $t('header.return_to_account') }}</span>
</router-link>
</template>
<router-link class="button is-white" to="/user/profile" v-if="SessionUser.authenticated.value">
<router-link class="button is-white" to="/user/profile" v-if="isAuthenticatedSessionReady">
<span class="icon">
<i class="fas fa-user-circle" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ SessionUser.authenticated.value ? SessionUser.getName() : ''}}</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ isAuthenticatedSessionReady ? SessionUser.getName() : ''}}</span>
</router-link>
<template v-else>
<template v-if="!SessionUser.authenticated.value">
<router-link class="button is-white" to="/register">
<span class="icon">
<i class="fas fa-user-plus" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>

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