Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83935af973 | ||
|
|
37973f8fcb | ||
|
|
acf19c281a | ||
|
|
683fce32da | ||
|
|
4c9ba7c30d | ||
|
|
ca1f3ab477 | ||
|
|
2ab09076f5 | ||
|
|
97e17fadd4 | ||
|
|
ef780e5542 | ||
|
|
931c31c7be | ||
|
|
25f2be875c | ||
|
|
ff70326665 | ||
|
|
52ed49211a | ||
|
|
0565fc2a7c | ||
|
|
d30273cda8 | ||
|
|
b893e69b54 | ||
|
|
eabb12ef97 | ||
|
|
225741cf7b | ||
|
|
dad42da6d9 | ||
|
|
0fa2284e89 | ||
|
|
cc241e5e19 | ||
|
|
c5a536fbea | ||
|
|
3a7fef11ef | ||
|
|
625757fafc | ||
|
|
e1a8af5a72 | ||
|
|
c437c89cfa | ||
|
|
184ef5670c | ||
|
|
8ab8915429 | ||
|
|
a952dd2c61 | ||
|
|
ebcd52a019 | ||
|
|
b2ddf0db5c | ||
|
|
eaf4b965d5 | ||
|
|
6ba1c754a1 | ||
|
|
468613ab68 | ||
|
|
261d84bd01 | ||
|
|
ea8db593c5 | ||
|
|
130cc2fc10 | ||
|
|
3715da8fd3 | ||
|
|
100e477a95 | ||
|
|
1132c8c256 | ||
|
|
42237fcc70 | ||
|
|
ae8b3b6b64 | ||
|
|
8378491b0b | ||
|
|
20274cf8eb | ||
|
|
d25726fafd | ||
|
|
4f1973d048 | ||
|
|
d69c474655 | ||
|
|
56078307da | ||
|
|
e6291cdb8a | ||
|
|
bf0b2152c5 | ||
|
|
9771723992 | ||
|
|
156e50a864 | ||
|
|
5081fe0776 | ||
|
|
656e43d449 | ||
|
|
d135a3f0af | ||
|
|
7bd880497f | ||
|
|
d148c0d6b9 | ||
|
|
75bab7b425 | ||
|
|
8dfaebba00 | ||
|
|
f7f01dcd2f | ||
|
|
fba85a72c1 | ||
|
|
c657ec57ad | ||
|
|
f24df440d0 | ||
|
|
b73f83a4ab | ||
|
|
2700ff3526 | ||
|
|
a261f2f3bd | ||
|
|
81c4ba0800 | ||
|
|
31e72af9bf | ||
|
|
10a52521d5 | ||
|
|
c9b34ba36c | ||
|
|
87ad8f9d31 | ||
|
|
a754cfc7bb | ||
|
|
29bc0e548b | ||
|
|
378598b161 | ||
|
|
e8c1dda3cc | ||
|
|
fabc4ad54a | ||
|
|
b89ae2bb9e | ||
|
|
fb162eef6c | ||
|
|
dc2840e023 | ||
|
|
fe6d9e8d16 | ||
|
|
7ab7d83fb5 | ||
|
|
028dad2183 | ||
|
|
6b0fe60958 | ||
|
|
a7789cb631 | ||
|
|
da65876a64 | ||
|
|
f245161bfa | ||
|
|
bdffdca420 | ||
|
|
5d1199a133 | ||
|
|
4752b3d096 | ||
|
|
fc9c3700a1 | ||
|
|
a15d6841c5 | ||
|
|
9641f67e3a | ||
|
|
de2fa57c63 | ||
|
|
a658fe7863 | ||
|
|
503fb84f10 | ||
|
|
11bb8f824d | ||
|
|
8615cc6678 | ||
|
|
65e4c27f45 | ||
|
|
f9d8b87768 | ||
|
|
586a0cc86d | ||
|
|
e9cbb40559 | ||
|
|
f2c3962749 | ||
|
|
e63e113628 | ||
|
|
4b02de7358 | ||
|
|
61aab76e63 | ||
|
|
e26e37d9d7 | ||
|
|
d411da2a12 | ||
|
|
15b3c726dc | ||
|
|
ee47c801ea | ||
|
|
d0993d2963 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue -type f \\\\\\(-name *.ts -o -name *.js \\\\\\) -not -path */node_modules* -not -path */dist/* -not -path */.git/* -not -path */dev-dist/*)",
|
||||
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue/src -type f \\\\\\(-name *.ts -o -name *.js -o -name *.vue \\\\\\))",
|
||||
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue/app -type f \\\\\\(-name *.ts -o -name *.js -o -name *.java -o -name *.kt \\\\\\))",
|
||||
"Bash(grep -r \"getAutomaticFlags\\\\|AutomaticFlags\\\\|automaticFlags\\\\|Automatic.*Flags\" /c/Users/2jepp/WebstormProjects/pleno-vue --include=*.ts --include=*.js --include=*.java --include=*.kt --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=dev-dist --exclude-dir=output --exclude-dir=.git)",
|
||||
"Bash(xargs grep:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
coverage
|
||||
dev-dist
|
||||
dist
|
||||
node_modules
|
||||
node_modules.*
|
||||
output
|
||||
playwright-report
|
||||
test-results
|
||||
.ai
|
||||
.ai-workflow
|
||||
.claude
|
||||
.codex
|
||||
.gradle
|
||||
gradle
|
||||
@@ -0,0 +1,15 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.webm binary
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Frontend Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-upload-and-verify:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
env:
|
||||
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
|
||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
||||
RELEASE_WAIT_INITIAL_SECONDS: 45
|
||||
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
||||
RELEASE_POLL_INTERVAL_SECONDS: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Check AI workflow sync
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
|
||||
- name: Source and i18n checks
|
||||
run: |
|
||||
npm run text:check-encoding
|
||||
npm run i18n:v2:source-check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
|
||||
- name: Build release artifact
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Production Playwright gate
|
||||
run: npm run test:e2e:prod
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
||||
path: dist
|
||||
retention-days: 14
|
||||
|
||||
- name: Wait for Coolify release artifact
|
||||
run: npm run release:verify-upload
|
||||
|
||||
- name: Public live Playwright gate
|
||||
run: npm run test:e2e:live:public
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live Playwright gate
|
||||
run: npm run test:e2e:live:roles
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||
|
||||
- name: Record Release Manager gate
|
||||
run: |
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
curl --fail --show-error --silent \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"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
|
||||
+30
-11
@@ -61,17 +61,38 @@ jobs:
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
|
||||
e2e-smoke:
|
||||
e2e-pr:
|
||||
if: github.event_name != 'schedule'
|
||||
needs: build-and-unit
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
project: [chromium-desktop, chromium-mobile]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve Playwright diff refs
|
||||
id: playwright-diff
|
||||
shell: bash
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zero_sha="0000000000000000000000000000000000000000"
|
||||
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
|
||||
base_ref="$PR_BASE_SHA"
|
||||
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
|
||||
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
|
||||
base_ref="origin/$DEFAULT_BRANCH"
|
||||
else
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
fi
|
||||
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
@@ -85,17 +106,15 @@ jobs:
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright smoke tests (${{ matrix.project }})
|
||||
run: npx playwright test --grep @smoke --project="${{ matrix.project }}"
|
||||
- name: Run Playwright PR tests
|
||||
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-smoke-${{ matrix.project }}
|
||||
path: |
|
||||
output/playwright/report
|
||||
output/playwright/test-results
|
||||
name: playwright-report-pr
|
||||
path: output/playwright
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
3.0
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:24-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
ARG SOURCE_COMMIT
|
||||
ARG RELEASE_COMMIT_SHA
|
||||
ARG COMMIT_SHA
|
||||
ARG GITHUB_SHA
|
||||
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
ENV RELEASE_COMMIT_SHA=$RELEASE_COMMIT_SHA
|
||||
ENV COMMIT_SHA=$COMMIT_SHA
|
||||
ENV GITHUB_SHA=$GITHUB_SHA
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.coolify-frontend.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -22,6 +22,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
|
||||
|
||||
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_APP_VERSION: string,
|
||||
readonly VITE_COMMIT_HASH: string,
|
||||
readonly VITE_IS_DEV: string,
|
||||
readonly VITE_RELEASE_SOURCE: 'local' | 'deployment' | 'auto',
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
|
||||
+5
-6
@@ -2,11 +2,10 @@
|
||||
<html lang="" class="theme-light" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/png" href="/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
@@ -16,6 +15,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script type="module" src="/src/releaseBootstrap.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
+776
-3
@@ -3056,6 +3056,8 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Orders
|
||||
x-api-coverage:
|
||||
happy: true
|
||||
summary: List orders
|
||||
description: Retrieve a paginated list of orders
|
||||
operationId: listOrders
|
||||
@@ -3375,7 +3377,7 @@ paths:
|
||||
tags:
|
||||
- Departments
|
||||
summary: List departments
|
||||
description: Retrieve a list of all visible departments
|
||||
description: Retrieve visible, active departments by default. Superuser department access may filter archived departments with `filters=archived:1`.
|
||||
operationId: listDepartments
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -3386,6 +3388,11 @@ paths:
|
||||
- $ref: '#/components/parameters/PageParam'
|
||||
- $ref: '#/components/parameters/PerPageParam'
|
||||
- $ref: '#/components/parameters/SearchParam'
|
||||
- name: filters
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated field filters. `archived:1` is only honored for users with superuser department access.
|
||||
responses:
|
||||
'200':
|
||||
description: Departments retrieved successfully
|
||||
@@ -4644,6 +4651,22 @@ paths:
|
||||
customer_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_type:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Optional product/vehicle type override used when refreshing the self-serve summary.
|
||||
vehicle_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the answer mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully added vehicle condition
|
||||
@@ -4688,6 +4711,22 @@ paths:
|
||||
customer_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_type:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Optional product/vehicle type override used when refreshing the self-serve summary.
|
||||
vehicle_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully updated vehicle condition
|
||||
@@ -4740,6 +4779,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Check whether self-serve is allowed for a vehicle on a lane
|
||||
description: Customers with own self-serve permissions may evaluate any registration plate for their wash. Persisted self-serve answers are only applied when they are scoped to the authenticated customer.
|
||||
operationId: getSelfserveVehicleAllowed
|
||||
parameters:
|
||||
- name: lane_id
|
||||
@@ -5168,6 +5208,228 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/department/selfserve/studio/graph:
|
||||
get:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Get all-in-one self-serve studio graph
|
||||
description: Returns the replacement studio workspace graph with nodes, edges, resolved lookup labels, validation, layout, versioning, simulator defaults, gateway workspace, and permissions. Vehicle type lookups and scope nodes are derived from selectable wash products.
|
||||
operationId: getSelfserveStudioGraph
|
||||
parameters:
|
||||
- name: department
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Studio graph returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioGraph'
|
||||
put:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Bulk save all-in-one self-serve studio graph changes
|
||||
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, rules, tasks, scopes, attachments metadata, and gateway references while keeping layout separate from runtime behavior.
|
||||
operationId: saveSelfserveStudioGraph
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioGraphSaveRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Studio graph saved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioGraph'
|
||||
'422':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/department/selfserve/studio/layout:
|
||||
put:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Save self-serve studio canvas layout
|
||||
description: Persists canvas-only node positions and viewport state. Layout does not affect runtime wash behavior.
|
||||
operationId: saveSelfserveStudioLayout
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioLayoutSaveRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Layout saved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioLayout'
|
||||
|
||||
/department/selfserve/studio/validate:
|
||||
post:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Validate self-serve studio graph
|
||||
operationId: validateSelfserveStudioGraph
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [department]
|
||||
properties:
|
||||
department:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Validation result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioValidation'
|
||||
|
||||
/department/selfserve/studio/simulate:
|
||||
post:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Simulate self-serve studio runtime
|
||||
operationId: simulateSelfserveStudioGraph
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [department, lane_id, reg]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
lane_id: { type: integer }
|
||||
reg: { type: string }
|
||||
customer_number: { type: integer, nullable: true }
|
||||
vehicle_type_id: { type: integer, nullable: true }
|
||||
config_source:
|
||||
type: string
|
||||
enum: [draft, published]
|
||||
default: draft
|
||||
answer_overrides:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [question_id]
|
||||
properties:
|
||||
question_id: { type: integer }
|
||||
value:
|
||||
type: boolean
|
||||
nullable: true
|
||||
include_hardware:
|
||||
type: boolean
|
||||
default: true
|
||||
mode:
|
||||
type: string
|
||||
enum: [full_dry_run]
|
||||
default: full_dry_run
|
||||
responses:
|
||||
'200':
|
||||
description: Simulator result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveStudioSimulationResponse'
|
||||
|
||||
/department/selfserve/studio/publish:
|
||||
post:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Publish self-serve studio draft
|
||||
operationId: publishSelfserveStudioDraft
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [department]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Published version
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveConfigVersion'
|
||||
|
||||
/department/selfserve/studio/rollback:
|
||||
post:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Roll back self-serve studio to an earlier version
|
||||
operationId: rollbackSelfserveStudioDraft
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [department, target_version_id]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
target_version_id: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Rollback version
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveConfigVersion'
|
||||
|
||||
/department/selfserve/studio/gateway-action:
|
||||
post:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Run permission-gated edge gateway action from studio
|
||||
operationId: runSelfserveStudioGatewayAction
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [department, gateway_id, action]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
gateway_id: { type: integer }
|
||||
action:
|
||||
type: string
|
||||
enum: [discovery, discover, update, uninstall, cancel, rotate_credentials, bindings]
|
||||
confirm:
|
||||
type: boolean
|
||||
description: Required for dangerous gateway actions such as uninstall and credential rotation.
|
||||
operation_id: { type: integer, nullable: true }
|
||||
request:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
bindings:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
'200':
|
||||
description: Gateway action result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
# Products Endpoints
|
||||
/products:
|
||||
get:
|
||||
@@ -6133,6 +6395,34 @@ paths:
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/collected-invoices/economic/queue/monitor:
|
||||
get:
|
||||
tags:
|
||||
- Invoices
|
||||
summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs
|
||||
operationId: monitorCollectedInvoiceEconomicQueueJobs
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 50
|
||||
responses:
|
||||
'200':
|
||||
description: Queue monitor state retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EconomicTransferQueueMonitorResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/collected-invoices/economic/queue/status:
|
||||
get:
|
||||
tags:
|
||||
@@ -6192,6 +6482,57 @@ paths:
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/collected-invoices/economic/queue/dismiss:
|
||||
post:
|
||||
tags:
|
||||
- Invoices
|
||||
summary: Clear one completed or failed collected-invoice queue job for the current user
|
||||
operationId: dismissCollectedInvoiceEconomicQueueJob
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id]
|
||||
properties:
|
||||
job_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Queue job cleared
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EconomicTransferQueueDismissResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/collected-invoices/economic/queue/dismiss-terminal:
|
||||
post:
|
||||
tags:
|
||||
- Invoices
|
||||
summary: Clear all visible completed or failed collected-invoice queue jobs for the current user
|
||||
operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs
|
||||
responses:
|
||||
'200':
|
||||
description: Terminal queue jobs cleared
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/collected-invoices/economic/queue/run:
|
||||
post:
|
||||
tags:
|
||||
@@ -8483,6 +8824,7 @@ paths:
|
||||
description: |
|
||||
Send a command (e.g., start, stop, reset) to a self-serve lane.
|
||||
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
|
||||
Property gate command permissions are bypassed for authenticated customers with an active self-serve wash in the target department.
|
||||
operationId: sendSelfServeLaneCommand
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -8502,6 +8844,13 @@ paths:
|
||||
license_plate:
|
||||
type: string
|
||||
description: Required for START command
|
||||
customer_number:
|
||||
type: integer
|
||||
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
|
||||
defer_relay_side_effects:
|
||||
type: boolean
|
||||
default: false
|
||||
description: For START, open the entrance gate as part of the command but defer cleaner and machine relay activation to explicit relay endpoints.
|
||||
responses:
|
||||
'200':
|
||||
description: Command sent successfully
|
||||
@@ -13446,6 +13795,140 @@ components:
|
||||
- meta
|
||||
- includes
|
||||
|
||||
EconomicTransferQueueMonitorResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
jobs:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EconomicTransferQueueJob'
|
||||
counts:
|
||||
type: object
|
||||
properties:
|
||||
queued:
|
||||
type: integer
|
||||
minimum: 0
|
||||
in_progress:
|
||||
type: integer
|
||||
minimum: 0
|
||||
failed:
|
||||
type: integer
|
||||
minimum: 0
|
||||
completed:
|
||||
type: integer
|
||||
minimum: 0
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
required:
|
||||
- queued
|
||||
- in_progress
|
||||
- failed
|
||||
- completed
|
||||
- total
|
||||
progress_percent:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
required:
|
||||
- jobs
|
||||
- counts
|
||||
- progress_percent
|
||||
- limit
|
||||
meta:
|
||||
oneOf:
|
||||
- type: array
|
||||
items: {}
|
||||
- type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
oneOf:
|
||||
- type: array
|
||||
items: {}
|
||||
- type: object
|
||||
additionalProperties: true
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
- includes
|
||||
|
||||
EconomicTransferQueueDismissResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
job:
|
||||
$ref: '#/components/schemas/EconomicTransferQueueJob'
|
||||
required:
|
||||
- message
|
||||
- job
|
||||
meta:
|
||||
oneOf:
|
||||
- type: array
|
||||
items: {}
|
||||
- type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
oneOf:
|
||||
- type: array
|
||||
items: {}
|
||||
- type: object
|
||||
additionalProperties: true
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
- includes
|
||||
|
||||
EconomicTransferQueueDismissTerminalResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
dismissed_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
required:
|
||||
- message
|
||||
- dismissed_count
|
||||
meta:
|
||||
oneOf:
|
||||
- type: array
|
||||
items: {}
|
||||
- type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
oneOf:
|
||||
- type: array
|
||||
items: {}
|
||||
- type: object
|
||||
additionalProperties: true
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
- includes
|
||||
|
||||
CollectedInvoiceEconomicCompareResponse:
|
||||
type: object
|
||||
description: Result of comparing a collected invoice with its E-conomic counterpart
|
||||
@@ -14561,6 +15044,203 @@ components:
|
||||
enum:
|
||||
- MACHINE
|
||||
|
||||
SelfserveStudioNode:
|
||||
type: object
|
||||
required: [id, position, data]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
nullable: true
|
||||
position:
|
||||
type: object
|
||||
required: [x, y]
|
||||
properties:
|
||||
x: { type: number }
|
||||
y: { type: number }
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [question, condition, rule, task, lane, machine_type, vehicle_type, edge_gateway, relay_binding, relay, runtime_checkpoint]
|
||||
object_id:
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
nullable: true
|
||||
label:
|
||||
type: string
|
||||
raw:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
SelfserveStudioEdge:
|
||||
type: object
|
||||
required: [id, source, target]
|
||||
properties:
|
||||
id: { type: string }
|
||||
source: { type: string }
|
||||
target: { type: string }
|
||||
type: { type: string, nullable: true }
|
||||
label: { type: string, nullable: true }
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
SelfserveStudioLayout:
|
||||
type: object
|
||||
properties:
|
||||
nodes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
x: { type: number }
|
||||
y: { type: number }
|
||||
viewport:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
runtime_affecting:
|
||||
type: boolean
|
||||
enum: [false]
|
||||
SelfserveStudioValidation:
|
||||
type: object
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
errors:
|
||||
type: array
|
||||
items: { type: string }
|
||||
warnings:
|
||||
type: array
|
||||
items: { type: string }
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
severity:
|
||||
type: string
|
||||
enum: [error, warning]
|
||||
message:
|
||||
type: string
|
||||
stats:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
validated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
SelfserveConfigVersion:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
department_id: { type: integer }
|
||||
status:
|
||||
type: string
|
||||
enum: [DRAFT, PUBLISHED, ARCHIVED]
|
||||
version_number: { type: integer }
|
||||
config:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
validation_result:
|
||||
$ref: '#/components/schemas/SelfserveStudioValidation'
|
||||
source_version_id: { type: integer, nullable: true }
|
||||
created_by: { type: integer, nullable: true }
|
||||
published_at: { type: string, nullable: true }
|
||||
created_at: { type: string, nullable: true }
|
||||
updated_at: { type: string, nullable: true }
|
||||
SelfserveStudioGraph:
|
||||
type: object
|
||||
required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions]
|
||||
properties:
|
||||
nodes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioNode'
|
||||
edges:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioEdge'
|
||||
lookups:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
validation:
|
||||
$ref: '#/components/schemas/SelfserveStudioValidation'
|
||||
layout:
|
||||
$ref: '#/components/schemas/SelfserveStudioLayout'
|
||||
versions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveConfigVersion'
|
||||
active_config:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
draft:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
simulator_defaults:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
gateway_workspace:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
permissions:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
SelfserveStudioGraphOperation:
|
||||
type: object
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [create, update, delete, connect, disconnect, reorder]
|
||||
entity:
|
||||
type: string
|
||||
enum: [question, condition, rule, task]
|
||||
id:
|
||||
type: integer
|
||||
nullable: true
|
||||
source:
|
||||
type: string
|
||||
nullable: true
|
||||
target:
|
||||
type: string
|
||||
nullable: true
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
SelfserveStudioGraphSaveRequest:
|
||||
type: object
|
||||
required: [department]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
operations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioGraphOperation'
|
||||
nodes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SelfserveStudioNode'
|
||||
layout:
|
||||
$ref: '#/components/schemas/SelfserveStudioLayout'
|
||||
SelfserveStudioLayoutSaveRequest:
|
||||
type: object
|
||||
required: [department, layout]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
layout:
|
||||
$ref: '#/components/schemas/SelfserveStudioLayout'
|
||||
SelfserveMachineType:
|
||||
type: object
|
||||
properties:
|
||||
@@ -14731,6 +15411,75 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
SelfserveStudioSimulationDebug:
|
||||
type: object
|
||||
required: [summary, parameters, stages, questions, conditions, rules, tasks, hardware, graph_annotations, recommendations]
|
||||
properties:
|
||||
summary:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
parameters:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
stages:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
questions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
rules:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
tasks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
hardware:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
graph_annotations:
|
||||
type: object
|
||||
properties:
|
||||
nodes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
edges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
recommendations:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SelfserveStudioSimulationResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SelfserveVehicleAllowedResponse'
|
||||
- type: object
|
||||
properties:
|
||||
simulator_version: { type: integer }
|
||||
dry_run: { type: boolean, enum: [true] }
|
||||
mode: { type: string, enum: [full_dry_run] }
|
||||
config_source: { type: string, enum: [draft, published] }
|
||||
debug:
|
||||
$ref: '#/components/schemas/SelfserveStudioSimulationDebug'
|
||||
|
||||
SelfserveVehicleAllowedResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -14770,10 +15519,20 @@ components:
|
||||
type: boolean
|
||||
allowed:
|
||||
type: boolean
|
||||
blocked_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
session:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SelfserveWashSession'
|
||||
nullable: true
|
||||
config_source:
|
||||
type: string
|
||||
nullable: true
|
||||
evaluation_trace:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
|
||||
SelfserveWashSummary:
|
||||
type: object
|
||||
@@ -15113,6 +15872,8 @@ components:
|
||||
type: integer
|
||||
visible:
|
||||
type: boolean
|
||||
archived:
|
||||
type: boolean
|
||||
dimension:
|
||||
type: integer
|
||||
branding:
|
||||
@@ -15168,6 +15929,8 @@ components:
|
||||
type: integer
|
||||
machine_available:
|
||||
type: boolean
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
|
||||
DepartmentCreate:
|
||||
type: object
|
||||
@@ -15183,6 +15946,8 @@ components:
|
||||
type: integer
|
||||
visible:
|
||||
type: boolean
|
||||
archived:
|
||||
type: boolean
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
@@ -15203,6 +15968,8 @@ components:
|
||||
type: string
|
||||
visible:
|
||||
type: boolean
|
||||
archived:
|
||||
type: boolean
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
@@ -15236,6 +16003,9 @@ components:
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
status:
|
||||
type: string
|
||||
created_at:
|
||||
@@ -15272,6 +16042,9 @@ components:
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
DepartmentLaneUpdate:
|
||||
type: object
|
||||
@@ -15301,6 +16074,8 @@ components:
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
|
||||
DepartmentGate:
|
||||
type: object
|
||||
@@ -17254,5 +18029,3 @@ components:
|
||||
success: { type: boolean, example: true }
|
||||
data:
|
||||
$ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload'
|
||||
|
||||
|
||||
|
||||
Generated
+53
-3
@@ -41,6 +41,7 @@
|
||||
"chart.js": "^4.4.7",
|
||||
"dayspan-vuetify": "^0.4.0",
|
||||
"flow": "^0.2.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "11.22.4",
|
||||
@@ -1949,9 +1950,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@bubblewrap/core/node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
@@ -5678,6 +5679,15 @@
|
||||
"zxing-wasm": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -6492,6 +6502,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -8237,6 +8256,19 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
@@ -12149,6 +12181,15 @@
|
||||
"integrity": "sha512-pv4GjmramjdObhDyR1q85Td8X60Puu/lGQn7Kw2id05LLgHhAcWgnz6xSdMCSxBMWjQDmMyDXPTC2aqADdpiow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/throat": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz",
|
||||
@@ -12654,6 +12695,15 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
|
||||
+23
-3
@@ -16,9 +16,23 @@
|
||||
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
|
||||
"text:check-encoding": "node scripts/text-encoding.mjs check",
|
||||
"test:unit": "npm run text:check-encoding && node scripts/run-vitest-unit-batches.mjs",
|
||||
"i18n:v2:compile": "node scripts/i18n-v2-compile-source.mjs",
|
||||
"i18n:v2:source-check": "node scripts/i18n-v2-compile-source.mjs --check",
|
||||
"i18n:v2:check": "npm run i18n:v2:source-check && npm run i18n:v2:global-template-audit && npm run i18n:v2:template-dedupe-audit && npm run i18n:v2:word-audit",
|
||||
"i18n:v2:word-audit": "node scripts/i18n-v2-word-dedupe.mjs",
|
||||
"i18n:v2:word-dedupe": "node scripts/i18n-v2-word-dedupe.mjs --apply",
|
||||
"i18n:v2:global-template-audit": "node scripts/i18n-v2-globalize-templates.mjs",
|
||||
"i18n:v2:globalize-templates": "node scripts/i18n-v2-globalize-templates.mjs --apply",
|
||||
"i18n:v2:template-dedupe-audit": "node scripts/i18n-v2-template-dedupe.mjs",
|
||||
"i18n:v2:template-dedupe": "node scripts/i18n-v2-template-dedupe.mjs --apply",
|
||||
"test:unit": "npm run text:check-encoding && npm run test:unit:fast && npm run test:unit:serial",
|
||||
"test:unit:fast": "node scripts/run-vitest-unit-fast.mjs",
|
||||
"test:unit:serial": "node scripts/run-vitest-unit-batches.mjs --from-list tests/unit/serial-tests.txt",
|
||||
"test:unit:single": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e": "npm run test:e2e:matrix --",
|
||||
"test:e2e:matrix": "playwright test",
|
||||
"test:e2e:pr": "node scripts/run-playwright-pr.mjs",
|
||||
"test:e2e:changed": "node scripts/run-playwright-pr.mjs --changed-only",
|
||||
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
|
||||
"test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs",
|
||||
"test:e2e:full:slice": "node scripts/run-playwright-full-slice.mjs",
|
||||
@@ -28,9 +42,14 @@
|
||||
"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:smoke",
|
||||
"test:all": "npm run test:unit && npm run test:e2e:pr",
|
||||
"twa:build": "bubblewrap build",
|
||||
"twa:update": "bubblewrap update"
|
||||
},
|
||||
@@ -67,6 +86,7 @@
|
||||
"chart.js": "^4.4.7",
|
||||
"dayspan-vuetify": "^0.4.0",
|
||||
"flow": "^0.2.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "11.22.4",
|
||||
|
||||
+57
-29
@@ -1,4 +1,5 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
@@ -11,15 +12,16 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}
|
||||
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
|
||||
const serverLogDir = path.resolve(process.cwd(), "output/playwright");
|
||||
const pidFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.json`);
|
||||
const stdoutLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stdout.log`);
|
||||
const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stderr.log`);
|
||||
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
|
||||
const serverOutputLimit = 80;
|
||||
const activeOutputReaders = [];
|
||||
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
|
||||
const viteDevArgs = [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--force",
|
||||
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
|
||||
...(process.platform === "win32" ? ["--configLoader", "runner"] : []),
|
||||
"--host",
|
||||
devHost,
|
||||
@@ -176,14 +178,18 @@ async function killProcessTree(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
function captureProcessOutput(stream, lines) {
|
||||
function captureProcessOutput(stream, lines, logStream) {
|
||||
const reader = readline.createInterface({ input: stream });
|
||||
reader.on("line", (line) => {
|
||||
lines.push(line);
|
||||
logStream.write(`${line}\n`);
|
||||
if (lines.length > serverOutputLimit) {
|
||||
lines.splice(0, lines.length - serverOutputLimit);
|
||||
}
|
||||
});
|
||||
reader.on("close", () => {
|
||||
logStream.end();
|
||||
});
|
||||
return reader;
|
||||
}
|
||||
|
||||
@@ -368,6 +374,10 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}
|
||||
}
|
||||
|
||||
for (const specifier of extractModuleImports(source)) {
|
||||
if (specifier.startsWith("/node_modules/.vite/deps/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedContentType = resolveExpectedContentType(specifier);
|
||||
const importUrl = new URL(specifier, current.url).toString();
|
||||
|
||||
@@ -386,12 +396,36 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}
|
||||
|
||||
async function warmUpDevServer(url) {
|
||||
const warmupTargets = await buildWarmupTargets();
|
||||
const selfServeEntryModules = [
|
||||
"/src/views/dashboards/userDashboard/wash/MyWash.vue",
|
||||
"/src/views/dashboards/userDashboard/wash/MyWashStart.vue",
|
||||
"/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue",
|
||||
"/src/views/guest/book/GuestBookExteriorWash.vue",
|
||||
];
|
||||
const smokeRouteEntryModules = [
|
||||
{ path: "/src/components/page/wrappers/LayoutV2.vue", depth: 2 },
|
||||
{ path: "/src/views/pages/LandingPage.vue", depth: 2 },
|
||||
{ path: "/src/views/dashboards/superUserDashboard/selfserve/EdgeAgents.vue", depth: 1 },
|
||||
{ path: "/src/features/edgeGateways/EdgeGatewayManager.vue", depth: 2 },
|
||||
{ path: "/src/features/edgeGateways/EdgeGatewayTerminalPage.vue", depth: 2 },
|
||||
{ path: "/src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue", depth: 1 },
|
||||
{ path: "/src/features/edgeGateways/EdgeGatewayDepartmentWorkspace.vue", depth: 2 },
|
||||
{ path: "/src/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue", depth: 1 },
|
||||
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue", depth: 2 },
|
||||
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue", depth: 2 },
|
||||
];
|
||||
|
||||
for (const target of warmupTargets) {
|
||||
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
|
||||
}
|
||||
|
||||
await warmModuleGraph(new URL("/src/main.js", url).toString());
|
||||
for (const entryModule of selfServeEntryModules) {
|
||||
await warmModuleGraph(new URL(entryModule, url).toString(), { depth: 2 });
|
||||
}
|
||||
for (const { path: entryModule, depth } of smokeRouteEntryModules) {
|
||||
await warmModuleGraph(new URL(entryModule, url).toString(), { depth });
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
@@ -412,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();
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
RewriteRule ^index\.html$ - [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule \.[^/]+$ - [R=404,L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . index.html [L]
|
||||
</IfModule>
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "Access your Truck Wash accounts and transactions from anywhere.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicons/web-app-manifest-192x192.png",
|
||||
"src": "assets/favicons/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/favicons/web-app-manifest-512x512.png",
|
||||
"src": "assets/favicons/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const sourceDirectory = path.join(projectRoot, "src", "i18n", "source");
|
||||
const generatedDirectory = path.join(projectRoot, "src", "i18n", "generated");
|
||||
const activeLocales = ["da", "en", "sv", "de", "no"];
|
||||
const mode = process.argv.includes("--check") ? "check" : "write";
|
||||
|
||||
const linkTokenPattern = /@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))/gu;
|
||||
|
||||
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
|
||||
|
||||
const stableJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
||||
|
||||
const writeJson = (filePath, value) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, stableJson(value), "utf8");
|
||||
};
|
||||
|
||||
const listJsonFiles = (directory) => {
|
||||
if (!fs.existsSync(directory)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||
return entries
|
||||
.flatMap((entry) => {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return listJsonFiles(entryPath);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : [];
|
||||
})
|
||||
.sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
|
||||
};
|
||||
|
||||
const mergeMessages = (left = {}, right = {}) => {
|
||||
const merged = { ...left };
|
||||
|
||||
for (const [key, value] of Object.entries(right ?? {})) {
|
||||
if (isPlainObject(value) && isPlainObject(merged[key])) {
|
||||
merged[key] = mergeMessages(merged[key], value);
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const readJsonFragments = (directory) => {
|
||||
let merged = {};
|
||||
for (const filePath of listJsonFiles(directory)) {
|
||||
merged = mergeMessages(merged, readJson(filePath));
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const mapSourcePathToRuntimePath = (keyPath) => {
|
||||
if (keyPath.startsWith("terms.glossary.")) {
|
||||
return `words.generated.${keyPath.slice("terms.glossary.".length)}`;
|
||||
}
|
||||
|
||||
if (keyPath.startsWith("terms.")) {
|
||||
return `words.${keyPath.slice("terms.".length)}`;
|
||||
}
|
||||
|
||||
if (keyPath.startsWith("phrases.compat.")) {
|
||||
return `templates.generated.compat.${keyPath.slice("phrases.compat.".length)}`;
|
||||
}
|
||||
|
||||
if (keyPath.startsWith("phrases.")) {
|
||||
return `templates.${keyPath.slice("phrases.".length)}`;
|
||||
}
|
||||
|
||||
return keyPath;
|
||||
};
|
||||
|
||||
const rewriteLinks = (value) => {
|
||||
if (typeof value === "string") {
|
||||
return value.replace(linkTokenPattern, (token, modifier = "", literalTarget, pathTarget) => {
|
||||
const target = literalTarget ?? pathTarget;
|
||||
const mappedTarget = mapSourcePathToRuntimePath(target);
|
||||
const quoteTarget = literalTarget || /[^\p{L}\p{N}_.-]/u.test(mappedTarget);
|
||||
return quoteTarget ? `@${modifier}:{'${mappedTarget}'}` : `@${modifier}:${mappedTarget}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => rewriteLinks(entry));
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, rewriteLinks(entry)]));
|
||||
};
|
||||
|
||||
const compileWords = (sourceTerms) => {
|
||||
const { glossary, ...terms } = sourceTerms;
|
||||
return {
|
||||
grammar: terms.grammar ?? {},
|
||||
actions: terms.actions ?? {},
|
||||
entities: terms.entities ?? {},
|
||||
statuses: terms.statuses ?? {},
|
||||
domain: terms.domain ?? {},
|
||||
services: terms.services ?? {},
|
||||
units: terms.units ?? {},
|
||||
replication: terms.replication ?? {},
|
||||
generated: glossary ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
const compileTemplates = (sourcePhrases) => {
|
||||
const { compat, ...phrases } = sourcePhrases;
|
||||
return {
|
||||
...phrases,
|
||||
generated: {
|
||||
compat: compat ?? {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const compileLocale = (locale) => {
|
||||
const localeSourceDirectory = path.join(sourceDirectory, locale);
|
||||
const sourceTerms = readJsonFragments(path.join(localeSourceDirectory, "terms"));
|
||||
const sourcePhrases = readJsonFragments(path.join(localeSourceDirectory, "phrases"));
|
||||
|
||||
return rewriteLinks({
|
||||
words: compileWords(sourceTerms),
|
||||
templates: compileTemplates(sourcePhrases),
|
||||
});
|
||||
};
|
||||
|
||||
const compileGlobal = () => {
|
||||
const shared = readJsonFragments(path.join(sourceDirectory, "global", "shared"));
|
||||
const locales = {};
|
||||
|
||||
for (const locale of activeLocales) {
|
||||
locales[locale] = readJsonFragments(path.join(sourceDirectory, "global", "locales", locale));
|
||||
}
|
||||
|
||||
return rewriteLinks({
|
||||
shared,
|
||||
locales,
|
||||
});
|
||||
};
|
||||
|
||||
const compiledFiles = new Map([
|
||||
[path.join(generatedDirectory, "global-v2.json"), compileGlobal()],
|
||||
...activeLocales.map((locale) => [path.join(generatedDirectory, `${locale}-v2.json`), compileLocale(locale)]),
|
||||
]);
|
||||
|
||||
if (mode === "check") {
|
||||
const mismatches = [];
|
||||
for (const [filePath, value] of compiledFiles.entries()) {
|
||||
const expected = stableJson(value);
|
||||
const actual = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "") : "";
|
||||
if (actual !== expected) {
|
||||
mismatches.push(path.relative(projectRoot, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
console.error("Generated i18n v2 runtime files are out of date:");
|
||||
for (const mismatch of mismatches) {
|
||||
console.error(` ${mismatch}`);
|
||||
}
|
||||
console.error("Run npm run i18n:v2:compile.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Generated i18n v2 runtime files are up to date.");
|
||||
} else {
|
||||
for (const [filePath, value] of compiledFiles.entries()) {
|
||||
writeJson(filePath, value);
|
||||
}
|
||||
console.log(`Compiled i18n v2 runtime files to ${path.relative(projectRoot, generatedDirectory)}.`);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
|
||||
const activeLocales = ["da", "en", "sv", "de", "no"];
|
||||
const mode = process.argv.includes("--apply") ? "apply" : "check";
|
||||
|
||||
const templatePrefix = "templates.generated.compat";
|
||||
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
|
||||
const exactLinkPattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
|
||||
|
||||
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
|
||||
|
||||
const writeJson = (filePath, value) => {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
};
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
const flattenStringEntries = (value, prefix = "") => {
|
||||
if (typeof value === "string") {
|
||||
return [{ key: prefix, value }];
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(value).flatMap(([key, entry]) =>
|
||||
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
|
||||
);
|
||||
};
|
||||
|
||||
const getValueAtPath = (value, keyPath) => {
|
||||
let current = value;
|
||||
for (const segment of keyPath.split(".")) {
|
||||
if (!isPlainObject(current) || !(segment in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const ensureObjectAtPath = (value, keyPath) => {
|
||||
let current = value;
|
||||
for (const segment of keyPath.split(".")) {
|
||||
if (!isPlainObject(current[segment])) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const setValueAtPath = (value, keyPath, nextValue) => {
|
||||
const segments = keyPath.split(".");
|
||||
const parent = ensureObjectAtPath(value, segments.slice(0, -1).join("."));
|
||||
parent[segments.at(-1)] = nextValue;
|
||||
};
|
||||
|
||||
const deleteValueAtPath = (value, keyPath) => {
|
||||
const segments = keyPath.split(".");
|
||||
const parents = [];
|
||||
let current = value;
|
||||
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
if (!isPlainObject(current) || !(segment in current)) {
|
||||
return false;
|
||||
}
|
||||
parents.push([current, segment]);
|
||||
current = current[segment];
|
||||
}
|
||||
|
||||
if (!isPlainObject(current) || !(segments.at(-1) in current)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete current[segments.at(-1)];
|
||||
|
||||
for (let index = parents.length - 1; index >= 0; index -= 1) {
|
||||
const [parent, segment] = parents[index];
|
||||
const child = parent[segment];
|
||||
if (isPlainObject(child) && Object.keys(child).length === 0) {
|
||||
delete parent[segment];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const mergeMessages = (sharedMessages = {}, localeMessages = {}) => {
|
||||
const mergedMessages = { ...sharedMessages };
|
||||
|
||||
for (const [key, value] of Object.entries(localeMessages ?? {})) {
|
||||
if (isPlainObject(value) && isPlainObject(mergedMessages[key])) {
|
||||
mergedMessages[key] = mergeMessages(mergedMessages[key], value);
|
||||
} else {
|
||||
mergedMessages[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return mergedMessages;
|
||||
};
|
||||
|
||||
const applyTextModifier = (value, modifier) => {
|
||||
switch (modifier) {
|
||||
case ".capitalize":
|
||||
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
|
||||
case ".upper":
|
||||
return value.toLocaleUpperCase();
|
||||
case ".lower":
|
||||
return value.toLocaleLowerCase();
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
|
||||
if (seen.has(keyPath)) {
|
||||
return undefined;
|
||||
}
|
||||
seen.add(keyPath);
|
||||
|
||||
const value = getValueAtPath(messages, keyPath);
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const exactLink = value.trim().match(exactLinkPattern);
|
||||
if (exactLink) {
|
||||
const target = exactLink.groups.literal ?? exactLink.groups.path;
|
||||
const resolved = resolveLinkedMessage(messages, target, seen);
|
||||
return resolved === undefined ? undefined : applyTextModifier(resolved, exactLink.groups.modifier);
|
||||
}
|
||||
|
||||
return value.replace(linkTokenPattern, (token) => {
|
||||
const match = token.match(exactLinkPattern);
|
||||
if (!match?.groups) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const target = match.groups.literal ?? match.groups.path;
|
||||
const resolved = resolveLinkedMessage(messages, target, new Set(seen));
|
||||
return resolved === undefined ? token : applyTextModifier(resolved, match.groups.modifier);
|
||||
});
|
||||
};
|
||||
|
||||
const readRawLocales = () =>
|
||||
Object.fromEntries(
|
||||
activeLocales.map((locale) => [locale, readJson(path.join(localesDirectory, `${locale}-v2.json`))])
|
||||
);
|
||||
|
||||
const buildMergedLocales = (globalMessages, rawLocales) =>
|
||||
Object.fromEntries(
|
||||
activeLocales.map((locale) => [
|
||||
locale,
|
||||
mergeMessages(
|
||||
mergeMessages(globalMessages.shared ?? {}, globalMessages.locales?.[locale] ?? {}),
|
||||
rawLocales[locale]
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
const isCatalogPhraseKey = (keyPath) => !keyPath.startsWith("words.") && !keyPath.startsWith("templates.");
|
||||
|
||||
const exactLinkTarget = (value) => {
|
||||
const match = value.trim().match(exactLinkPattern);
|
||||
return match?.groups?.literal ?? match?.groups?.path;
|
||||
};
|
||||
|
||||
const templateKeyFor = (keyPath) => `${templatePrefix}.${keyPath}`;
|
||||
|
||||
const linkedTemplateFor = (keyPath) => `@:{'${templateKeyFor(keyPath)}'}`;
|
||||
|
||||
const sourceValueFor = (globalMessages, rawLocales, locale, keyPath) => {
|
||||
const rawValue = getValueAtPath(rawLocales[locale], keyPath);
|
||||
if (typeof rawValue === "string") {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
const localeValue = getValueAtPath(globalMessages.locales?.[locale] ?? {}, keyPath);
|
||||
if (typeof localeValue === "string") {
|
||||
return localeValue;
|
||||
}
|
||||
|
||||
const sharedValue = getValueAtPath(globalMessages.shared ?? {}, keyPath);
|
||||
return typeof sharedValue === "string" ? sharedValue : undefined;
|
||||
};
|
||||
|
||||
const collectCandidateKeys = (globalMessages, rawLocales) => {
|
||||
const keys = new Set();
|
||||
|
||||
for (const locale of activeLocales) {
|
||||
for (const entry of flattenStringEntries(rawLocales[locale]).filter((entry) => isCatalogPhraseKey(entry.key))) {
|
||||
keys.add(entry.key);
|
||||
}
|
||||
|
||||
for (const entry of flattenStringEntries(globalMessages.locales?.[locale] ?? {}).filter((entry) =>
|
||||
isCatalogPhraseKey(entry.key)
|
||||
)) {
|
||||
const target = exactLinkTarget(entry.value);
|
||||
if (target !== templateKeyFor(entry.key)) {
|
||||
keys.add(entry.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...keys].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
|
||||
};
|
||||
|
||||
const reorderLocaleRoot = (localeMessages) => {
|
||||
const { words, templates, ...rest } = localeMessages;
|
||||
return { words, templates, ...rest };
|
||||
};
|
||||
|
||||
const pruneLocaleMap = (locales) => {
|
||||
const nextLocales = {};
|
||||
for (const locale of activeLocales) {
|
||||
nextLocales[locale] = isPlainObject(locales?.[locale]) ? locales[locale] : {};
|
||||
}
|
||||
return nextLocales;
|
||||
};
|
||||
|
||||
const transformCatalogs = (originalGlobalMessages, originalRawLocales) => {
|
||||
const globalMessages = cloneJson(originalGlobalMessages);
|
||||
const rawLocales = cloneJson(originalRawLocales);
|
||||
globalMessages.shared = globalMessages.shared ?? {};
|
||||
globalMessages.locales = pruneLocaleMap(globalMessages.locales ?? {});
|
||||
|
||||
const originalMergedLocales = buildMergedLocales(originalGlobalMessages, originalRawLocales);
|
||||
const candidateKeys = collectCandidateKeys(originalGlobalMessages, originalRawLocales);
|
||||
let directSharedAliasCount = 0;
|
||||
let generatedTemplateCount = 0;
|
||||
let removedRawEntryCount = 0;
|
||||
let removedLocaleEntryCount = 0;
|
||||
|
||||
for (const keyPath of candidateKeys) {
|
||||
const valuesByLocale = Object.fromEntries(
|
||||
activeLocales.map((locale) => {
|
||||
const sourceValue = sourceValueFor(originalGlobalMessages, originalRawLocales, locale, keyPath);
|
||||
if (typeof sourceValue === "string") {
|
||||
return [locale, sourceValue];
|
||||
}
|
||||
|
||||
const mergedValue = getValueAtPath(originalMergedLocales[locale], keyPath);
|
||||
return [locale, typeof mergedValue === "string" ? mergedValue : undefined];
|
||||
})
|
||||
);
|
||||
|
||||
const values = activeLocales.map((locale) => valuesByLocale[locale]);
|
||||
if (values.some((value) => typeof value !== "string")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstValue = values[0];
|
||||
const allValuesIdentical = values.every((value) => value === firstValue);
|
||||
const canUseDirectSharedAlias =
|
||||
allValuesIdentical &&
|
||||
!firstValue.includes("words.generated.") &&
|
||||
!firstValue.includes(templatePrefix) &&
|
||||
exactLinkTarget(firstValue) !== keyPath;
|
||||
|
||||
if (canUseDirectSharedAlias) {
|
||||
setValueAtPath(globalMessages.shared, keyPath, firstValue);
|
||||
directSharedAliasCount += 1;
|
||||
} else {
|
||||
setValueAtPath(globalMessages.shared, keyPath, linkedTemplateFor(keyPath));
|
||||
for (const locale of activeLocales) {
|
||||
setValueAtPath(rawLocales[locale], templateKeyFor(keyPath), valuesByLocale[locale]);
|
||||
}
|
||||
generatedTemplateCount += 1;
|
||||
}
|
||||
|
||||
for (const locale of activeLocales) {
|
||||
if (deleteValueAtPath(rawLocales[locale], keyPath)) {
|
||||
removedRawEntryCount += 1;
|
||||
}
|
||||
if (deleteValueAtPath(globalMessages.locales[locale], keyPath)) {
|
||||
removedLocaleEntryCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const locale of activeLocales) {
|
||||
rawLocales[locale] = reorderLocaleRoot(rawLocales[locale]);
|
||||
globalMessages.locales[locale] = isPlainObject(globalMessages.locales[locale])
|
||||
? globalMessages.locales[locale]
|
||||
: {};
|
||||
}
|
||||
|
||||
const nextMergedLocales = buildMergedLocales(globalMessages, rawLocales);
|
||||
const mismatches = [];
|
||||
for (const locale of activeLocales) {
|
||||
for (const entry of flattenStringEntries(originalMergedLocales[locale])) {
|
||||
const expected = resolveLinkedMessage(originalMergedLocales[locale], entry.key);
|
||||
const actual = resolveLinkedMessage(nextMergedLocales[locale], entry.key);
|
||||
if (actual !== expected) {
|
||||
mismatches.push(
|
||||
`${locale}.${entry.key}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remainingRawPhraseEntries = activeLocales.flatMap((locale) =>
|
||||
flattenStringEntries(rawLocales[locale])
|
||||
.filter((entry) => isCatalogPhraseKey(entry.key))
|
||||
.map((entry) => `${locale}.${entry.key}`)
|
||||
);
|
||||
|
||||
const remainingLocalePhraseEntries = activeLocales.flatMap((locale) =>
|
||||
flattenStringEntries(globalMessages.locales?.[locale] ?? {})
|
||||
.filter((entry) => isCatalogPhraseKey(entry.key))
|
||||
.map((entry) => `${locale}.${entry.key}`)
|
||||
);
|
||||
|
||||
return {
|
||||
globalMessages,
|
||||
rawLocales,
|
||||
candidateCount: candidateKeys.length,
|
||||
directSharedAliasCount,
|
||||
generatedTemplateCount,
|
||||
removedRawEntryCount,
|
||||
removedLocaleEntryCount,
|
||||
mismatches,
|
||||
remainingRawPhraseEntries,
|
||||
remainingLocalePhraseEntries,
|
||||
};
|
||||
};
|
||||
|
||||
const globalPath = path.join(localesDirectory, "global-v2.json");
|
||||
const originalGlobalMessages = readJson(globalPath);
|
||||
const originalRawLocales = readRawLocales();
|
||||
const result = transformCatalogs(originalGlobalMessages, originalRawLocales);
|
||||
|
||||
console.log(
|
||||
[
|
||||
`candidate phrase keys: ${result.candidateCount}`,
|
||||
`direct shared aliases: ${result.directSharedAliasCount}`,
|
||||
`generated shared template aliases: ${result.generatedTemplateCount}`,
|
||||
`removed raw locale entries: ${result.removedRawEntryCount}`,
|
||||
`removed global locale entries: ${result.removedLocaleEntryCount}`,
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
if (result.mismatches.length > 0) {
|
||||
hasErrors = true;
|
||||
console.error(`transformed output changed ${result.mismatches.length} strings`);
|
||||
for (const mismatch of result.mismatches.slice(0, 30)) {
|
||||
console.error(` ${mismatch}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.remainingRawPhraseEntries.length > 0) {
|
||||
hasErrors = true;
|
||||
console.error(`raw locale phrase entries remain outside words/templates: ${result.remainingRawPhraseEntries.length}`);
|
||||
for (const entry of result.remainingRawPhraseEntries.slice(0, 30)) {
|
||||
console.error(` ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.remainingLocalePhraseEntries.length > 0) {
|
||||
hasErrors = true;
|
||||
console.error(`global-v2 locale phrase entries remain: ${result.remainingLocalePhraseEntries.length}`);
|
||||
for (const entry of result.remainingLocalePhraseEntries.slice(0, 30)) {
|
||||
console.error(` ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (mode === "apply") {
|
||||
writeJson(globalPath, result.globalMessages);
|
||||
for (const locale of activeLocales) {
|
||||
writeJson(path.join(localesDirectory, `${locale}-v2.json`), result.rawLocales[locale]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
|
||||
const activeLocales = ["da", "en", "sv", "de", "no"];
|
||||
const mode = process.argv.includes("--apply") ? "apply" : "check";
|
||||
|
||||
const templatePrefix = "templates.generated.compat";
|
||||
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
|
||||
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)$/u;
|
||||
|
||||
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
|
||||
|
||||
const writeJson = (filePath, value) => {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
};
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
const flattenStringEntries = (value, prefix = "") => {
|
||||
if (typeof value === "string") {
|
||||
return [{ key: prefix, value }];
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(value).flatMap(([key, entry]) =>
|
||||
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
|
||||
);
|
||||
};
|
||||
|
||||
const getValueAtPath = (value, keyPath) => {
|
||||
let current = value;
|
||||
for (const segment of keyPath.split(".")) {
|
||||
if (!isPlainObject(current) || !(segment in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const ensureObjectAtPath = (value, keyPath) => {
|
||||
let current = value;
|
||||
if (!keyPath) {
|
||||
return current;
|
||||
}
|
||||
|
||||
for (const segment of keyPath.split(".")) {
|
||||
if (!isPlainObject(current[segment])) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const setValueAtPath = (value, keyPath, nextValue) => {
|
||||
const segments = keyPath.split(".");
|
||||
const parent = ensureObjectAtPath(value, segments.slice(0, -1).join("."));
|
||||
parent[segments.at(-1)] = nextValue;
|
||||
};
|
||||
|
||||
const deleteValueAtPath = (value, keyPath) => {
|
||||
const segments = keyPath.split(".");
|
||||
const parents = [];
|
||||
let current = value;
|
||||
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
if (!isPlainObject(current) || !(segment in current)) {
|
||||
return false;
|
||||
}
|
||||
parents.push([current, segment]);
|
||||
current = current[segment];
|
||||
}
|
||||
|
||||
if (!isPlainObject(current) || !(segments.at(-1) in current)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete current[segments.at(-1)];
|
||||
|
||||
for (let index = parents.length - 1; index >= 0; index -= 1) {
|
||||
const [parent, segment] = parents[index];
|
||||
const child = parent[segment];
|
||||
if (isPlainObject(child) && Object.keys(child).length === 0) {
|
||||
delete parent[segment];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const normalizePhrase = (value) =>
|
||||
value
|
||||
.normalize("NFKC")
|
||||
.toLocaleLowerCase()
|
||||
.replace(placeholderPattern, "{}")
|
||||
.replace(/[\p{P}\p{S}]+/gu, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const linkToTemplate = (suffix) => `@:{'${templatePrefix}.${suffix}'}`;
|
||||
|
||||
const canonicalPriority = (suffix) => {
|
||||
if (suffix.startsWith("common.")) return 0;
|
||||
if (suffix.startsWith("global.")) return 1;
|
||||
if (suffix.startsWith("nav.")) return 2;
|
||||
if (suffix.startsWith("tables.common.")) return 3;
|
||||
if (suffix.startsWith("objects.columns.")) return 4;
|
||||
if (suffix.startsWith("auth.")) return 5;
|
||||
if (suffix.startsWith("products.")) return 6;
|
||||
return 10;
|
||||
};
|
||||
|
||||
const chooseCanonicalSuffix = (suffixes) =>
|
||||
[...suffixes].sort(
|
||||
(left, right) =>
|
||||
canonicalPriority(left) - canonicalPriority(right) ||
|
||||
left.split(".").length - right.split(".").length ||
|
||||
left.length - right.length ||
|
||||
left.localeCompare(right, "en", { sensitivity: "base" })
|
||||
)[0];
|
||||
|
||||
const createDisjointSet = (items) => {
|
||||
const parent = new Map(items.map((item) => [item, item]));
|
||||
|
||||
const find = (item) => {
|
||||
const currentParent = parent.get(item);
|
||||
if (currentParent === item) {
|
||||
return item;
|
||||
}
|
||||
const root = find(currentParent);
|
||||
parent.set(item, root);
|
||||
return root;
|
||||
};
|
||||
|
||||
const union = (left, right) => {
|
||||
const leftRoot = find(left);
|
||||
const rightRoot = find(right);
|
||||
if (leftRoot !== rightRoot) {
|
||||
parent.set(rightRoot, leftRoot);
|
||||
}
|
||||
};
|
||||
|
||||
const groups = () => {
|
||||
const grouped = new Map();
|
||||
for (const item of items) {
|
||||
const root = find(item);
|
||||
const group = grouped.get(root) ?? [];
|
||||
group.push(item);
|
||||
grouped.set(root, group);
|
||||
}
|
||||
return [...grouped.values()];
|
||||
};
|
||||
|
||||
return { union, groups };
|
||||
};
|
||||
|
||||
const replaceTemplatePathReferences = (value, replacements) => {
|
||||
if (typeof value === "string") {
|
||||
let nextValue = value;
|
||||
for (const [fromSuffix, toSuffix] of replacements.entries()) {
|
||||
nextValue = nextValue.replaceAll(`${templatePrefix}.${fromSuffix}`, `${templatePrefix}.${toSuffix}`);
|
||||
}
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => replaceTemplatePathReferences(entry, replacements));
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, replaceTemplatePathReferences(entry, replacements)])
|
||||
);
|
||||
};
|
||||
|
||||
const readRawLocales = () =>
|
||||
Object.fromEntries(
|
||||
activeLocales.map((locale) => [locale, readJson(path.join(localesDirectory, `${locale}-v2.json`))])
|
||||
);
|
||||
|
||||
const collectTemplateSuffixes = (rawLocales) => {
|
||||
const suffixes = new Set();
|
||||
for (const locale of activeLocales) {
|
||||
for (const entry of flattenStringEntries(getValueAtPath(rawLocales[locale], templatePrefix) ?? {})) {
|
||||
suffixes.add(entry.key);
|
||||
}
|
||||
}
|
||||
return [...suffixes].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
|
||||
};
|
||||
|
||||
const transformCatalogs = (originalGlobalMessages, originalRawLocales) => {
|
||||
const rawLocales = cloneJson(originalRawLocales);
|
||||
let globalMessages = cloneJson(originalGlobalMessages);
|
||||
const suffixes = collectTemplateSuffixes(rawLocales);
|
||||
const disjointSet = createDisjointSet(suffixes);
|
||||
|
||||
for (const locale of activeLocales) {
|
||||
const groupedByLocalePhrase = new Map();
|
||||
for (const suffix of suffixes) {
|
||||
const value = getValueAtPath(rawLocales[locale], `${templatePrefix}.${suffix}`);
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const signature = normalizePhrase(value);
|
||||
if (signature.length < 4) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = groupedByLocalePhrase.get(signature) ?? [];
|
||||
group.push(suffix);
|
||||
groupedByLocalePhrase.set(signature, group);
|
||||
}
|
||||
|
||||
for (const group of groupedByLocalePhrase.values()) {
|
||||
if (group.length <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [first, ...rest] = group;
|
||||
for (const suffix of rest) {
|
||||
disjointSet.union(first, suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateGroups = disjointSet.groups().filter((group) => group.length > 1);
|
||||
const replacements = new Map();
|
||||
|
||||
for (const group of duplicateGroups) {
|
||||
const canonicalSuffix = chooseCanonicalSuffix(group);
|
||||
for (const suffix of group) {
|
||||
if (suffix !== canonicalSuffix) {
|
||||
replacements.set(suffix, canonicalSuffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fromSuffix, toSuffix] of [...replacements.entries()]) {
|
||||
const seen = new Set([fromSuffix]);
|
||||
let finalSuffix = toSuffix;
|
||||
while (replacements.has(finalSuffix) && !seen.has(finalSuffix)) {
|
||||
seen.add(finalSuffix);
|
||||
finalSuffix = replacements.get(finalSuffix);
|
||||
}
|
||||
replacements.set(fromSuffix, finalSuffix);
|
||||
}
|
||||
|
||||
if (replacements.size > 0) {
|
||||
globalMessages = replaceTemplatePathReferences(globalMessages, replacements);
|
||||
for (const locale of activeLocales) {
|
||||
rawLocales[locale] = replaceTemplatePathReferences(rawLocales[locale], replacements);
|
||||
}
|
||||
}
|
||||
|
||||
let removedTemplateCount = 0;
|
||||
let repointedAliasCount = 0;
|
||||
for (const [fromSuffix, toSuffix] of replacements.entries()) {
|
||||
setValueAtPath(globalMessages.shared, fromSuffix, linkToTemplate(toSuffix));
|
||||
repointedAliasCount += 1;
|
||||
|
||||
for (const locale of activeLocales) {
|
||||
if (deleteValueAtPath(rawLocales[locale], `${templatePrefix}.${fromSuffix}`)) {
|
||||
removedTemplateCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remainingDuplicateGroups = [];
|
||||
for (const locale of activeLocales) {
|
||||
const nonLinkedTemplates = flattenStringEntries(getValueAtPath(rawLocales[locale], templatePrefix) ?? {}).filter(
|
||||
(entry) => !exactLinkPattern.test(entry.value.trim())
|
||||
);
|
||||
const byPhrase = new Map();
|
||||
for (const entry of nonLinkedTemplates) {
|
||||
const signature = normalizePhrase(entry.value);
|
||||
const group = byPhrase.get(signature) ?? [];
|
||||
group.push(entry.key);
|
||||
byPhrase.set(signature, group);
|
||||
}
|
||||
for (const [signature, group] of byPhrase.entries()) {
|
||||
if (signature.length >= 4 && group.length > 1) {
|
||||
remainingDuplicateGroups.push(`${locale}: ${signature} <- ${group.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
globalMessages,
|
||||
rawLocales,
|
||||
duplicateGroupCount: duplicateGroups.length,
|
||||
replacementCount: replacements.size,
|
||||
repointedAliasCount,
|
||||
removedTemplateCount,
|
||||
remainingDuplicateGroups,
|
||||
};
|
||||
};
|
||||
|
||||
const globalPath = path.join(localesDirectory, "global-v2.json");
|
||||
const originalGlobalMessages = readJson(globalPath);
|
||||
const originalRawLocales = readRawLocales();
|
||||
const result = transformCatalogs(originalGlobalMessages, originalRawLocales);
|
||||
|
||||
console.log(
|
||||
[
|
||||
`duplicate generated template groups: ${result.duplicateGroupCount}`,
|
||||
`template replacements: ${result.replacementCount}`,
|
||||
`repointed shared aliases: ${result.repointedAliasCount}`,
|
||||
`removed locale template entries: ${result.removedTemplateCount}`,
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
if (result.remainingDuplicateGroups.length > 0) {
|
||||
console.error(`near-duplicate generated templates remain: ${result.remainingDuplicateGroups.length}`);
|
||||
for (const group of result.remainingDuplicateGroups.slice(0, 30)) {
|
||||
console.error(` ${group}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (mode === "apply") {
|
||||
writeJson(globalPath, result.globalMessages);
|
||||
for (const locale of activeLocales) {
|
||||
writeJson(path.join(localesDirectory, `${locale}-v2.json`), result.rawLocales[locale]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
|
||||
const activeLocales = ["da", "en", "sv", "de", "no"];
|
||||
|
||||
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
|
||||
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
|
||||
const wordTokenPattern = /[\p{L}\p{N}]+/gu;
|
||||
const linkedMessagePattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
|
||||
|
||||
const mode = process.argv.includes("--apply") ? "apply" : "check";
|
||||
|
||||
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
|
||||
|
||||
const writeJson = (filePath, value) => {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
};
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
const flattenStringEntries = (value, prefix = "") => {
|
||||
if (typeof value === "string") {
|
||||
return [{ key: prefix, value }];
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(value).flatMap(([key, entry]) =>
|
||||
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
|
||||
);
|
||||
};
|
||||
|
||||
const getValueAtPath = (value, keyPath) => {
|
||||
let current = value;
|
||||
for (const segment of keyPath.split(".")) {
|
||||
if (!isPlainObject(current) || !(segment in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const setValueAtPath = (value, keyPath, nextValue) => {
|
||||
const segments = keyPath.split(".");
|
||||
let current = value;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
current = current[segment];
|
||||
}
|
||||
current[segments.at(-1)] = nextValue;
|
||||
};
|
||||
|
||||
const compareStringValues = (left, right) => left.localeCompare(right, "en", { sensitivity: "base" });
|
||||
|
||||
const protectSegments = (value) => {
|
||||
const protectedRanges = [];
|
||||
for (const pattern of [linkTokenPattern, placeholderPattern]) {
|
||||
pattern.lastIndex = 0;
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
protectedRanges.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length });
|
||||
}
|
||||
}
|
||||
|
||||
protectedRanges.sort((a, b) => a.start - b.start || b.end - a.end);
|
||||
const mergedRanges = [];
|
||||
for (const range of protectedRanges) {
|
||||
const previous = mergedRanges.at(-1);
|
||||
if (previous && range.start <= previous.end) {
|
||||
previous.end = Math.max(previous.end, range.end);
|
||||
} else {
|
||||
mergedRanges.push({ ...range });
|
||||
}
|
||||
}
|
||||
|
||||
const segments = [];
|
||||
let offset = 0;
|
||||
for (const range of mergedRanges) {
|
||||
if (offset < range.start) {
|
||||
segments.push({ protected: false, value: value.slice(offset, range.start) });
|
||||
}
|
||||
segments.push({ protected: true, value: value.slice(range.start, range.end) });
|
||||
offset = range.end;
|
||||
}
|
||||
if (offset < value.length) {
|
||||
segments.push({ protected: false, value: value.slice(offset) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
const normalizeToken = (token) => token.normalize("NFKC").toLocaleLowerCase();
|
||||
|
||||
const isAuditedToken = (token) => /\p{L}/u.test(token);
|
||||
|
||||
const collectTokens = (value) => {
|
||||
const tokens = [];
|
||||
for (const segment of protectSegments(value)) {
|
||||
if (segment.protected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
wordTokenPattern.lastIndex = 0;
|
||||
for (const match of segment.value.matchAll(wordTokenPattern)) {
|
||||
const token = match[0];
|
||||
if (isAuditedToken(token)) {
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const stripMarks = (value) => value.normalize("NFKD").replace(/\p{M}/gu, "");
|
||||
|
||||
const slugify = (token, usedKeys) => {
|
||||
const base =
|
||||
stripMarks(token)
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.replace(/_{2,}/g, "_") || "word";
|
||||
const prefixed = /^\p{N}/u.test(base) ? `word_${base}` : base;
|
||||
let key = prefixed;
|
||||
let suffix = 2;
|
||||
while (usedKeys.has(key)) {
|
||||
key = `${prefixed}_${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedKeys.add(key);
|
||||
return key;
|
||||
};
|
||||
|
||||
const isCapitalizedToken = (token) => {
|
||||
const lower = token.toLocaleLowerCase();
|
||||
return token === `${lower.charAt(0).toLocaleUpperCase()}${lower.slice(1)}` && token !== lower;
|
||||
};
|
||||
|
||||
const isUpperToken = (token) => token.length > 1 && token === token.toLocaleUpperCase() && token !== token.toLocaleLowerCase();
|
||||
|
||||
const isLowerToken = (token) => token === token.toLocaleLowerCase() && token !== token.toLocaleUpperCase();
|
||||
|
||||
const linkedWord = (keyPath, modifier = "") => `@${modifier}:{'${keyPath}'}`;
|
||||
|
||||
const applyTextModifier = (value, modifier) => {
|
||||
switch (modifier) {
|
||||
case ".capitalize":
|
||||
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
|
||||
case ".upper":
|
||||
return value.toLocaleUpperCase();
|
||||
case ".lower":
|
||||
return value.toLocaleLowerCase();
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const modifierForToken = (token, word) => {
|
||||
if (normalizeToken(token) !== normalizeToken(word.value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidateModifiers = ["", ".upper", ".capitalize", ".lower"];
|
||||
for (const modifier of candidateModifiers) {
|
||||
if (applyTextModifier(word.value, modifier) === token) {
|
||||
return modifier;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const replaceRepeatedTokens = (value, tokenToWord) =>
|
||||
protectSegments(value)
|
||||
.map((segment) => {
|
||||
if (segment.protected) {
|
||||
return segment.value;
|
||||
}
|
||||
|
||||
return segment.value.replace(wordTokenPattern, (token) => {
|
||||
const normalized = normalizeToken(token);
|
||||
const word = tokenToWord.get(normalized);
|
||||
if (!word || !isAuditedToken(token)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const exactVariant = word.variants?.get(token);
|
||||
if (exactVariant) {
|
||||
return linkedWord(exactVariant.path);
|
||||
}
|
||||
|
||||
const modifier = modifierForToken(token, word);
|
||||
if (modifier !== null) {
|
||||
return linkedWord(word.path, modifier);
|
||||
}
|
||||
|
||||
return token;
|
||||
});
|
||||
})
|
||||
.join("");
|
||||
|
||||
const applyLinkedModifier = (value, modifier) => {
|
||||
return applyTextModifier(value, modifier);
|
||||
};
|
||||
|
||||
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
|
||||
if (seen.has(keyPath)) {
|
||||
return undefined;
|
||||
}
|
||||
seen.add(keyPath);
|
||||
|
||||
const value = getValueAtPath(messages, keyPath);
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const exactLink = value.trim().match(linkedMessagePattern);
|
||||
if (exactLink) {
|
||||
const resolved = resolveLinkedMessage(messages, exactLink.groups.literal ?? exactLink.groups.path, seen);
|
||||
return resolved === undefined ? undefined : applyLinkedModifier(resolved, exactLink.groups.modifier);
|
||||
}
|
||||
|
||||
return value.replace(linkTokenPattern, (token) => {
|
||||
const match = token.match(linkedMessagePattern);
|
||||
if (!match?.groups) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const resolved = resolveLinkedMessage(messages, match.groups.literal ?? match.groups.path, new Set(seen));
|
||||
if (resolved === undefined) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return applyLinkedModifier(resolved, match.groups.modifier);
|
||||
});
|
||||
};
|
||||
|
||||
const reorderLocaleRoot = (localeMessages) => {
|
||||
const { words, templates, ...rest } = localeMessages;
|
||||
return { words, templates, ...rest };
|
||||
};
|
||||
|
||||
const transformLocale = (locale, originalMessages) => {
|
||||
const messages = cloneJson(originalMessages);
|
||||
const entries = flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."));
|
||||
const tokenStats = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const token of collectTokens(entry.value)) {
|
||||
const normalized = normalizeToken(token);
|
||||
const stat = tokenStats.get(normalized) ?? { count: 0, casings: new Map() };
|
||||
stat.count += 1;
|
||||
stat.casings.set(token, (stat.casings.get(token) ?? 0) + 1);
|
||||
tokenStats.set(normalized, stat);
|
||||
}
|
||||
}
|
||||
|
||||
const repeatedTokens = [...tokenStats.entries()]
|
||||
.filter(([, stat]) => stat.count > 1)
|
||||
.sort(([left], [right]) => compareStringValues(left, right));
|
||||
const existingWords = messages.words ?? {};
|
||||
const generatedWords = {};
|
||||
const tokenToWord = new Map();
|
||||
const usedKeys = new Set(Object.keys(existingWords.generated ?? {}));
|
||||
|
||||
for (const [normalized, stat] of repeatedTokens) {
|
||||
const existingWordEntry = flattenStringEntries(existingWords).find(
|
||||
(entry) => normalizeToken(entry.value) === normalized
|
||||
);
|
||||
if (existingWordEntry) {
|
||||
tokenToWord.set(normalized, { path: `words.${existingWordEntry.key}`, value: existingWordEntry.value });
|
||||
continue;
|
||||
}
|
||||
|
||||
const casings = [...stat.casings.entries()].sort(
|
||||
([leftToken, leftCount], [rightToken, rightCount]) =>
|
||||
rightCount - leftCount || compareStringValues(leftToken, rightToken)
|
||||
);
|
||||
const lowerCaseToken = casings.find(([token]) => token === normalized)?.[0];
|
||||
const canonical = lowerCaseToken ?? casings[0][0];
|
||||
const key = slugify(normalized, usedKeys);
|
||||
generatedWords[key] = canonical;
|
||||
const word = { path: `words.generated.${key}`, value: canonical, variants: new Map() };
|
||||
|
||||
for (const [casing] of casings) {
|
||||
if (modifierForToken(casing, word) !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const variantKey = slugify(`${normalized}_${casing}`, usedKeys);
|
||||
generatedWords[variantKey] = casing;
|
||||
word.variants.set(casing, { path: `words.generated.${variantKey}`, value: casing });
|
||||
}
|
||||
|
||||
tokenToWord.set(normalized, word);
|
||||
}
|
||||
|
||||
messages.words = {
|
||||
grammar: messages.words?.grammar ?? {},
|
||||
actions: messages.words?.actions ?? {},
|
||||
entities: messages.words?.entities ?? {},
|
||||
statuses: messages.words?.statuses ?? {},
|
||||
domain: messages.words?.domain ?? {},
|
||||
services: messages.words?.services ?? {},
|
||||
units: messages.words?.units ?? {},
|
||||
...(messages.words ?? {}),
|
||||
generated: {
|
||||
...(messages.words?.generated ?? {}),
|
||||
...Object.fromEntries(Object.entries(generatedWords).sort(([left], [right]) => compareStringValues(left, right))),
|
||||
},
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
const nextValue = replaceRepeatedTokens(entry.value, tokenToWord);
|
||||
if (nextValue !== entry.value) {
|
||||
setValueAtPath(messages, entry.key, nextValue);
|
||||
}
|
||||
}
|
||||
|
||||
const mismatches = [];
|
||||
for (const entry of entries) {
|
||||
const previousResolved = resolveLinkedMessage(originalMessages, entry.key);
|
||||
const resolved = resolveLinkedMessage(messages, entry.key);
|
||||
if (resolved !== previousResolved) {
|
||||
mismatches.push(`${entry.key}: expected ${JSON.stringify(previousResolved)}, got ${JSON.stringify(resolved)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: reorderLocaleRoot(messages),
|
||||
generatedWordCount: Object.keys(generatedWords).length,
|
||||
repeatedTokenCount: repeatedTokens.length,
|
||||
mismatches,
|
||||
};
|
||||
};
|
||||
|
||||
const countRepeatedTokensOutsideWords = (messages) => {
|
||||
const counts = new Map();
|
||||
for (const entry of flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."))) {
|
||||
for (const token of collectTokens(entry.value)) {
|
||||
const normalized = normalizeToken(token);
|
||||
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()].filter(([, count]) => count > 1);
|
||||
};
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
for (const locale of activeLocales) {
|
||||
const filePath = path.join(localesDirectory, `${locale}-v2.json`);
|
||||
const originalMessages = readJson(filePath);
|
||||
const result = transformLocale(locale, originalMessages);
|
||||
const repeatedAfter = countRepeatedTokensOutsideWords(result.messages);
|
||||
|
||||
if (result.mismatches.length > 0) {
|
||||
hasErrors = true;
|
||||
console.error(`${locale}: transformed output changed ${result.mismatches.length} strings`);
|
||||
for (const mismatch of result.mismatches.slice(0, 20)) {
|
||||
console.error(` ${mismatch}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (repeatedAfter.length > 0) {
|
||||
hasErrors = true;
|
||||
console.error(`${locale}: ${repeatedAfter.length} repeated token groups remain outside words`);
|
||||
for (const [token, count] of repeatedAfter.slice(0, 20)) {
|
||||
console.error(` ${token}: ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${locale}: ${result.repeatedTokenCount} repeated token groups, ${result.generatedWordCount} new generated word entries`
|
||||
);
|
||||
|
||||
if (mode === "apply" && !hasErrors) {
|
||||
writeJson(filePath, result.messages);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export const chromiumProjects = ["chromium-desktop", "chromium-mobile"];
|
||||
export const prGrep = "@pr";
|
||||
export const smokeGrep = "@smoke";
|
||||
|
||||
export const fallbackChangePatterns = [
|
||||
/^package(?:-lock)?\.json$/u,
|
||||
/^vite\.config\.js$/u,
|
||||
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
|
||||
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
|
||||
/^scripts\/run-playwright/u,
|
||||
/^tests\/e2e\/(?:support|fixtures)\//u,
|
||||
];
|
||||
|
||||
export const sourceMappings = [
|
||||
{
|
||||
name: "auth",
|
||||
patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u],
|
||||
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "public-routes",
|
||||
patterns: [/^src\/views\/pages\//u, /^src\/components\/page\//u, /^src\/router\.js$/u],
|
||||
specs: ["tests/e2e/navigation.smoke.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "booking",
|
||||
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
||||
specs: [
|
||||
"tests/e2e/booking-selfserve.smoke.spec.js",
|
||||
"tests/e2e/userBookWash.spec.ts",
|
||||
"tests/e2e/userBookings.spec.ts",
|
||||
],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "user-vehicles",
|
||||
patterns: [/^src\/components\/displays\/user\/vehicles\//u, /^src\/views\/dashboards\/userDashboard\/vehicles\//u],
|
||||
specs: ["tests/e2e/userVehicles.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "pos",
|
||||
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
|
||||
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "edge-gateways",
|
||||
patterns: [/edge[-/]?gateways?/iu, /edgeGateway/iu],
|
||||
specs: ["tests/e2e/edge-gateways.smoke.spec.js", "tests/e2e/edge-gateways.routes.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "invoicing",
|
||||
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
|
||||
specs: [
|
||||
"tests/e2e/invoicing-period.smoke.spec.js",
|
||||
"tests/e2e/invoice-distribution.smoke.spec.js",
|
||||
"tests/e2e/invoice-transfer-queue-history.spec.js",
|
||||
],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "workfeed",
|
||||
patterns: [/workfeed/iu],
|
||||
specs: ["tests/e2e/workfeed-config.smoke.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "system-status",
|
||||
patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu],
|
||||
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "self-serve",
|
||||
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
|
||||
specs: ["tests/e2e/self-serve-wash.spec.js", "tests/e2e/self-serve-studio-flow.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "i18n",
|
||||
patterns: [/^src\/i18n\//u, /i18n/iu, /locales/iu],
|
||||
specs: ["tests/e2e/i18n.smoke.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,50 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
function gitCommit() {
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = process.env.SERVER_UPDATE_TOKEN;
|
||||
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
|
||||
if (!token) {
|
||||
if (required) {
|
||||
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
|
||||
}
|
||||
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
|
||||
return;
|
||||
}
|
||||
|
||||
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
|
||||
if (!version) {
|
||||
throw new Error("Could not determine release version for server update.");
|
||||
}
|
||||
|
||||
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set("version", version);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
|
||||
}
|
||||
|
||||
console.log(`Server version updated to ${version}.`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,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"
|
||||
@@ -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 || "";
|
||||
const manifest = await fetchJson(baseUrl, "release-manifest.json");
|
||||
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||
|
||||
if (!manifest.build_id) {
|
||||
throw new Error("release-manifest.json is missing build_id");
|
||||
}
|
||||
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
|
||||
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
|
||||
}
|
||||
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
|
||||
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
|
||||
}
|
||||
if (releaseEntry.entry !== manifest.entry) {
|
||||
throw new Error("release-entry.json entry does not match release-manifest.json");
|
||||
}
|
||||
|
||||
const releaseCss = JSON.stringify(releaseEntry.css || []);
|
||||
const manifestCss = JSON.stringify(manifest.css || []);
|
||||
if (releaseCss !== manifestCss) {
|
||||
throw new Error("release-entry.json css does not match release-manifest.json");
|
||||
}
|
||||
|
||||
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
|
||||
for (const shellPath of shellPaths) {
|
||||
await verifyShell(baseUrl, shellPath);
|
||||
}
|
||||
|
||||
const assetUrls = unique([
|
||||
"release-manifest.json",
|
||||
"release-entry.json",
|
||||
manifest.entry,
|
||||
...(manifest.css || []),
|
||||
...(manifest.index_asset_urls || []),
|
||||
...(manifest.pwa_asset_urls || []),
|
||||
...(manifest.asset_urls || []),
|
||||
]);
|
||||
|
||||
for (const assetUrl of assetUrls) {
|
||||
if (assetUrl === "/index.html") {
|
||||
continue;
|
||||
}
|
||||
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
|
||||
}
|
||||
|
||||
return {
|
||||
build_id: manifest.build_id,
|
||||
commit_sha: manifest.commit_sha,
|
||||
assets: assetUrls.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseUrl = requiredUrl();
|
||||
const initialWaitSeconds = numberEnv("RELEASE_WAIT_INITIAL_SECONDS", 30);
|
||||
const timeoutSeconds = numberEnv("RELEASE_WAIT_TIMEOUT_SECONDS", 300);
|
||||
const pollIntervalSeconds = numberEnv("RELEASE_POLL_INTERVAL_SECONDS", 10);
|
||||
|
||||
if (initialWaitSeconds > 0) {
|
||||
console.log(`Waiting ${initialWaitSeconds}s before polling ${baseUrl}`);
|
||||
await sleep(initialWaitSeconds * 1000);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
let attempt = 0;
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
attempt += 1;
|
||||
try {
|
||||
const result = await verifyRelease(baseUrl);
|
||||
console.log(
|
||||
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`Release upload not ready on attempt ${attempt}: ${message}`);
|
||||
if (Date.now() > deadline) {
|
||||
break;
|
||||
}
|
||||
await sleep(pollIntervalSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Release upload verification timed out.");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,24 +1,34 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const workingDirectory = process.cwd();
|
||||
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
||||
const execFileAsync = promisify(execFile);
|
||||
const roles = ["customer", "subuser", "admin", "superuser"];
|
||||
export const roles = ["customer", "subuser", "admin", "superuser"];
|
||||
const listEntryPattern = /^\s+\[[^\]]+\]\s+›\s+(.+?):(\d+):(\d+)\s+›\s+(.+)\s*$/u;
|
||||
|
||||
const ownedFilesByRole = {
|
||||
export const ownedFilesByRole = {
|
||||
customer: [
|
||||
"auth.smoke.spec.js",
|
||||
"booking-selfserve.smoke.spec.js",
|
||||
"connectivityIssue.spec.ts",
|
||||
"example.spec.ts",
|
||||
"guest-book-wash-mobile.spec.ts",
|
||||
"i18n-catalog-switch.spec.ts",
|
||||
"i18n-v2-integrity.spec.ts",
|
||||
"i18n.smoke.spec.ts",
|
||||
"i18n.views.spec.ts",
|
||||
"navigation.smoke.spec.js",
|
||||
"qr-new-customer-layout.spec.ts",
|
||||
"release-bootstrap.spec.js",
|
||||
"release-channel-switched.spec.js",
|
||||
"release-channel-unavailable.spec.js",
|
||||
"release-update-widget.spec.js",
|
||||
"self-serve-wash.spec.js",
|
||||
"session-release-runtime.spec.ts",
|
||||
"user-orders.spec.ts",
|
||||
"userBookings.spec.ts",
|
||||
"userBookWash.spec.ts",
|
||||
@@ -52,6 +62,7 @@ const ownedFilesByRole = {
|
||||
"assign-draft-order-modal-layout.spec.ts",
|
||||
"change-invoice-collection.spec.ts",
|
||||
"change-customer.spec.ts",
|
||||
"default-mobile-redirect.spec.ts",
|
||||
"economic-queue-workflow.spec.js",
|
||||
"pos-customer-rules.spec.js",
|
||||
"pos-desktop-card-payments.spec.js",
|
||||
@@ -61,23 +72,40 @@ const ownedFilesByRole = {
|
||||
"pos.visual.spec.js",
|
||||
],
|
||||
superuser: [
|
||||
"coolify-infrastructure.spec.js",
|
||||
"edge-gateways.fleet-outline.spec.js",
|
||||
"edge-gateways.routes.spec.js",
|
||||
"edge-gateways.smoke.spec.js",
|
||||
"edge-gateways.visual.spec.js",
|
||||
"errorReports.spec.ts",
|
||||
"failover-config.source.spec.ts",
|
||||
"invoice-distribution.smoke.spec.js",
|
||||
"invoice-transfer-monitor.spec.ts",
|
||||
"invoice-transfer-queue-history.spec.js",
|
||||
"invoicing-period.smoke.spec.js",
|
||||
"issue-repro-duplicates-date.spec.js",
|
||||
"release-manager.spec.js",
|
||||
"self-serve-sessions.spec.js",
|
||||
"self-serve-studio-audit-navigation.spec.js",
|
||||
"self-serve-studio-flow.spec.js",
|
||||
"session-bootstrap.spec.ts",
|
||||
"superuser-bookings.spec.ts",
|
||||
"superuser-customer-complaints.spec.ts",
|
||||
"superuser-customers-mass-import.spec.ts",
|
||||
"superuser-department-branding.spec.js",
|
||||
"superuser-department-gates.spec.ts",
|
||||
"superuser-department-lanes.spec.ts",
|
||||
"superuser-departments-archive.spec.ts",
|
||||
"superuser-drafts.spec.ts",
|
||||
"superuser-products-layout.spec.ts",
|
||||
"superuser-system-status.smoke.spec.js",
|
||||
"superuser-users.spec.ts",
|
||||
"superuser-vehicles.smoke.spec.js",
|
||||
"workfeed-config.smoke.spec.js",
|
||||
],
|
||||
};
|
||||
|
||||
const titleRules = [
|
||||
export const titleRules = [
|
||||
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
|
||||
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
|
||||
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
|
||||
@@ -104,6 +132,11 @@ const titleRules = [
|
||||
file: "subuser-management.spec.ts",
|
||||
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
|
||||
},
|
||||
{
|
||||
role: "superuser",
|
||||
file: "subuser-management.spec.ts",
|
||||
patterns: [/^superusers can list and invite chauffeurs/i],
|
||||
},
|
||||
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
|
||||
];
|
||||
|
||||
@@ -188,7 +221,7 @@ function toBaseName(filePath) {
|
||||
return filePath.split(/[\\/]/u).pop() || filePath;
|
||||
}
|
||||
|
||||
function parseListedTests(listOutput) {
|
||||
export function parseListedTests(listOutput) {
|
||||
return listOutput
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => {
|
||||
@@ -210,7 +243,7 @@ function parseListedTests(listOutput) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function classifyTest(testEntry) {
|
||||
export function classifyTest(testEntry) {
|
||||
const matches = new Set();
|
||||
const directOwner = ownedFileToRole.get(testEntry.fileName);
|
||||
|
||||
@@ -297,8 +330,8 @@ async function runPlaywright(project, testListPath, forwardedArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const { options, forwardedArgs } = parseCliArgs(argv);
|
||||
validateOptions(options, forwardedArgs);
|
||||
|
||||
const listOutput = await listProjectTests(options.project, forwardedArgs);
|
||||
@@ -330,4 +363,17 @@ async function main() {
|
||||
await runPlaywright(options.project, testListPath, forwardedArgs);
|
||||
}
|
||||
|
||||
await main();
|
||||
async function isDirectRun() {
|
||||
if (!process.argv[1]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPath = await fs.realpath(fileURLToPath(import.meta.url));
|
||||
const invokedPath = await fs.realpath(process.argv[1]).catch(() => path.resolve(process.argv[1]));
|
||||
|
||||
return currentPath === invokedPath;
|
||||
}
|
||||
|
||||
if (await isDirectRun()) {
|
||||
await main();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { chromiumProjects, fallbackChangePatterns, prGrep, smokeGrep, sourceMappings } from "./playwright-pr-mapping.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const workingDirectory = process.cwd();
|
||||
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const activeChildren = new Set();
|
||||
let isShuttingDown = false;
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
void shutdown(signal).finally(() => {
|
||||
process.exit(130);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs) {
|
||||
const parsed = {
|
||||
help: false,
|
||||
changedOnly: false,
|
||||
listOnly: false,
|
||||
base: "",
|
||||
head: "",
|
||||
projects: [],
|
||||
forwardedArgs: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index += 1) {
|
||||
const value = rawArgs[index];
|
||||
|
||||
if (value === "--") {
|
||||
parsed.forwardedArgs.push(...rawArgs.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
if (value === "--help" || value === "-h") {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--changed-only") {
|
||||
parsed.changedOnly = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--list-only") {
|
||||
parsed.listOnly = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--base" && rawArgs[index + 1] !== undefined) {
|
||||
parsed.base = rawArgs[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("--base=")) {
|
||||
parsed.base = value.slice("--base=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--head" && rawArgs[index + 1] !== undefined) {
|
||||
parsed.head = rawArgs[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("--head=")) {
|
||||
parsed.head = value.slice("--head=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--project" && rawArgs[index + 1] !== undefined) {
|
||||
parsed.projects.push(rawArgs[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("--project=")) {
|
||||
parsed.projects.push(value.slice("--project=".length));
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed.forwardedArgs.push(value);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Playwright PR runner
|
||||
|
||||
Usage:
|
||||
node scripts/run-playwright-pr.mjs [options] [-- <playwright args>]
|
||||
|
||||
Options:
|
||||
--base <ref> Base ref for changed-area detection
|
||||
--head <ref> Head ref for changed-area detection. Default: HEAD
|
||||
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
|
||||
--changed-only Run changed-area selection without the core @pr gate
|
||||
--list-only List selected tests instead of running them
|
||||
-h, --help Show help
|
||||
|
||||
Examples:
|
||||
npm run test:e2e:pr
|
||||
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
|
||||
`);
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
return value.replace(/\\/g, "/").replace(/^\.\//u, "");
|
||||
}
|
||||
|
||||
function isZeroSha(value) {
|
||||
return /^0{40}$/u.test(value || "");
|
||||
}
|
||||
|
||||
function getSelectedProjects(projects = args.projects) {
|
||||
return projects.length > 0 ? projects : chromiumProjects;
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function sanitizeNamespace(value) {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
}
|
||||
|
||||
function getArtifactNamespace(suffix) {
|
||||
const baseNamespace = process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "pr";
|
||||
return sanitizeNamespace(`${baseNamespace}-${suffix}`);
|
||||
}
|
||||
|
||||
function buildProjectArgs(projects) {
|
||||
return projects.flatMap((project) => ["--project", project]);
|
||||
}
|
||||
|
||||
async function runPlaywright({ label, commandArgs, artifactSuffix }) {
|
||||
const finalArgs = ["test", ...commandArgs, ...args.forwardedArgs];
|
||||
|
||||
if (args.listOnly && !finalArgs.includes("--list")) {
|
||||
finalArgs.push("--list");
|
||||
}
|
||||
|
||||
console.log(`[playwright-pr] ${args.listOnly ? "Listing" : "Running"} ${label}: playwright ${finalArgs.join(" ")}`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [playwrightCliPath, ...finalArgs], {
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix),
|
||||
PLAYWRIGHT_REPORTER_MODE: "line-html",
|
||||
PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS || "1",
|
||||
},
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
activeChildren.add(child);
|
||||
child.on("close", (code) => {
|
||||
activeChildren.delete(child);
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function tryGitDiff(rangeArgs) {
|
||||
const { stdout } = await execFileAsync("git", ["diff", "--name-only", "--diff-filter=ACMR", ...rangeArgs], {
|
||||
cwd: workingDirectory,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
|
||||
return stdout
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => normalizePath(line.trim()))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function getChangedFiles() {
|
||||
const base = args.base.trim();
|
||||
const head = args.head.trim() || "HEAD";
|
||||
|
||||
if (base && !isZeroSha(base)) {
|
||||
try {
|
||||
return {
|
||||
files: await tryGitDiff([`${base}...${head}`]),
|
||||
source: `${base}...${head}`,
|
||||
};
|
||||
} catch {
|
||||
try {
|
||||
return {
|
||||
files: await tryGitDiff([`${base}..${head}`]),
|
||||
source: `${base}..${head}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
files: [],
|
||||
source: `${base}...${head}`,
|
||||
unavailable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
files: await tryGitDiff(["HEAD~1...HEAD"]),
|
||||
source: "HEAD~1...HEAD",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
files: [],
|
||||
source: "HEAD~1...HEAD",
|
||||
unavailable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function addSpec(selection, spec, projects) {
|
||||
const normalizedSpec = normalizePath(spec);
|
||||
const existingProjects = selection.specProjects.get(normalizedSpec) || new Set();
|
||||
|
||||
for (const project of projects) {
|
||||
existingProjects.add(project);
|
||||
}
|
||||
|
||||
selection.specProjects.set(normalizedSpec, existingProjects);
|
||||
}
|
||||
|
||||
function isE2eSpec(file) {
|
||||
return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file);
|
||||
}
|
||||
|
||||
function shouldFallback(file) {
|
||||
return fallbackChangePatterns.some((pattern) => pattern.test(file));
|
||||
}
|
||||
|
||||
function isFrontendSource(file) {
|
||||
return /^src\//u.test(file) || /^public\//u.test(file) || /^index\.html$/u.test(file);
|
||||
}
|
||||
|
||||
function selectChangedTests(changedFiles) {
|
||||
const selection = {
|
||||
specProjects: new Map(),
|
||||
mappedFiles: [],
|
||||
unmappedFiles: [],
|
||||
fallback: false,
|
||||
};
|
||||
|
||||
const selectedProjects = getSelectedProjects();
|
||||
|
||||
for (const rawFile of changedFiles) {
|
||||
const file = normalizePath(rawFile);
|
||||
|
||||
if (isE2eSpec(file)) {
|
||||
addSpec(selection, file, selectedProjects);
|
||||
selection.mappedFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldFallback(file)) {
|
||||
selection.fallback = true;
|
||||
selection.unmappedFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isFrontendSource(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = sourceMappings.filter((mapping) => mapping.patterns.some((pattern) => pattern.test(file)));
|
||||
|
||||
if (matches.length === 0) {
|
||||
selection.fallback = true;
|
||||
selection.unmappedFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
selection.mappedFiles.push(file);
|
||||
for (const mapping of matches) {
|
||||
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
|
||||
for (const spec of mapping.specs) {
|
||||
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
function groupSpecsByProjects(specProjects) {
|
||||
const groups = new Map();
|
||||
|
||||
for (const [spec, projects] of specProjects.entries()) {
|
||||
const projectList = unique([...projects]).sort();
|
||||
const key = projectList.join(",");
|
||||
const existing = groups.get(key) || {
|
||||
projects: projectList,
|
||||
specs: [],
|
||||
};
|
||||
existing.specs.push(spec);
|
||||
groups.set(key, existing);
|
||||
}
|
||||
|
||||
return [...groups.values()].map((group) => ({
|
||||
...group,
|
||||
specs: group.specs.sort(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function runCorePrGate() {
|
||||
const projects = getSelectedProjects();
|
||||
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) {
|
||||
const projects = getSelectedProjects();
|
||||
|
||||
if (selection.fallback) {
|
||||
console.log(
|
||||
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
|
||||
);
|
||||
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);
|
||||
if (groups.length === 0) {
|
||||
console.log("[playwright-pr] No changed-area E2E specs selected.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const [index, group] of groups.entries()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
console.error(`Received ${signal}. Stopping active Playwright PR children...`);
|
||||
|
||||
await Promise.all(
|
||||
[...activeChildren].map(
|
||||
(child) =>
|
||||
new Promise((resolve) => {
|
||||
if (!child.pid) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
killer.on("close", resolve);
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
resolve();
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.access(playwrightCliPath);
|
||||
|
||||
if (!args.changedOnly) {
|
||||
const coreCode = await runCorePrGate();
|
||||
if (coreCode !== 0) {
|
||||
process.exitCode = coreCode;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const changed = await getChangedFiles();
|
||||
if (changed.unavailable) {
|
||||
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[playwright-pr] Changed-area diff ${changed.source}: ${changed.files.length} file(s).`);
|
||||
const selection = selectChangedTests(changed.files);
|
||||
const changedCode = await runChangedSelection(selection);
|
||||
if (changedCode !== 0) {
|
||||
process.exitCode = changedCode;
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const workingDirectory = process.cwd();
|
||||
const workingDirectory = await fs.realpath(process.cwd());
|
||||
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
|
||||
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
@@ -23,6 +23,7 @@ function parseArgs(rawArgs) {
|
||||
const parsed = {
|
||||
help: false,
|
||||
batchSize: null,
|
||||
fromList: null,
|
||||
specFilters: [],
|
||||
forwardedArgs: [],
|
||||
};
|
||||
@@ -51,6 +52,17 @@ function parseArgs(rawArgs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--from-list" && rawArgs[index + 1]) {
|
||||
parsed.fromList = rawArgs[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("--from-list=")) {
|
||||
parsed.fromList = value.slice("--from-list=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("-")) {
|
||||
parsed.forwardedArgs.push(...rawArgs.slice(index));
|
||||
break;
|
||||
@@ -80,15 +92,31 @@ Usage:
|
||||
|
||||
Options:
|
||||
--batch-size <n> Spec files per batch. Default: 5
|
||||
--from-list <file> Read additional spec filters from a newline-delimited file
|
||||
-h, --help Show help
|
||||
|
||||
Examples:
|
||||
npm run test:unit
|
||||
npm run test:unit:serial
|
||||
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js
|
||||
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js -- --reporter=verbose
|
||||
`);
|
||||
}
|
||||
|
||||
async function readSpecFilterList(listFile) {
|
||||
if (!listFile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const listPath = path.isAbsolute(listFile) ? listFile : path.join(workingDirectory, listFile);
|
||||
const contents = await fs.readFile(listPath, "utf8");
|
||||
|
||||
return contents
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.replace(/#.*/u, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function collectSpecFiles(targetDirectory) {
|
||||
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
|
||||
const files = [];
|
||||
@@ -234,7 +262,8 @@ async function main() {
|
||||
await fs.access(vitestCliPath);
|
||||
|
||||
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
|
||||
const selectedFiles = applyFilters(discoveredFiles, args.specFilters);
|
||||
const listFilters = await readSpecFilterList(args.fromList);
|
||||
const selectedFiles = applyFilters(discoveredFiles, [...listFilters, ...args.specFilters]);
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
throw new Error("No unit spec files matched the provided filters.");
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const workingDirectory = await fs.realpath(process.cwd());
|
||||
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
|
||||
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
|
||||
const serialListPath = path.join(unitTestsRoot, "serial-tests.txt");
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
let activeChild = null;
|
||||
let isShuttingDown = false;
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
void shutdown(signal).finally(() => {
|
||||
process.exit(130);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs) {
|
||||
const parsed = {
|
||||
help: false,
|
||||
specFilters: [],
|
||||
forwardedArgs: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index += 1) {
|
||||
const value = rawArgs[index];
|
||||
|
||||
if (value === "--") {
|
||||
parsed.forwardedArgs.push(...rawArgs.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
if (value === "--help" || value === "-h") {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("-")) {
|
||||
parsed.forwardedArgs.push(...rawArgs.slice(index));
|
||||
break;
|
||||
}
|
||||
|
||||
parsed.specFilters.push(value);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizePathSegments(value) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Vitest fast unit runner
|
||||
|
||||
Usage:
|
||||
node scripts/run-vitest-unit-fast.mjs [spec-filter ...] [-- <vitest args>]
|
||||
|
||||
Runs all unit specs except entries in tests/unit/serial-tests.txt.
|
||||
|
||||
Environment:
|
||||
VITEST_FAST_MAX_WORKERS Optional max workers for the fast pass
|
||||
|
||||
Examples:
|
||||
npm run test:unit:fast
|
||||
npm run test:unit:fast -- tests/unit/workfeed
|
||||
npm run test:unit:fast -- --reporter=verbose
|
||||
`);
|
||||
}
|
||||
|
||||
async function collectSpecFiles(targetDirectory) {
|
||||
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of directoryEntries) {
|
||||
const absolutePath = path.join(targetDirectory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectSpecFiles(absolutePath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith(".spec.js")) {
|
||||
files.push(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function readSerialAllowlist() {
|
||||
const contents = await fs.readFile(serialListPath, "utf8");
|
||||
|
||||
return new Set(
|
||||
contents
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.replace(/#.*/u, "").trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizePathSegments)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesFilter(relativePath, rawFilter) {
|
||||
const normalizedRelativePath = normalizePathSegments(relativePath);
|
||||
const normalizedFilter = normalizePathSegments(rawFilter.trim());
|
||||
|
||||
if (!normalizedFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedRelativePath === normalizedFilter ||
|
||||
normalizedRelativePath.endsWith(normalizedFilter) ||
|
||||
normalizedRelativePath.includes(normalizedFilter)
|
||||
);
|
||||
}
|
||||
|
||||
function applyFilters(files, specFilters) {
|
||||
if (specFilters.length === 0) {
|
||||
return files;
|
||||
}
|
||||
|
||||
return files.filter((absolutePath) => {
|
||||
const relativePath = path.relative(workingDirectory, absolutePath);
|
||||
return specFilters.some((filter) => matchesFilter(relativePath, filter));
|
||||
});
|
||||
}
|
||||
|
||||
function hasForwardedArg(name) {
|
||||
return args.forwardedArgs.some((value) => value === name || value.startsWith(`${name}=`));
|
||||
}
|
||||
|
||||
function resolveMaxWorkers() {
|
||||
return (process.env.VITEST_FAST_MAX_WORKERS || "").trim();
|
||||
}
|
||||
|
||||
async function runVitest(files) {
|
||||
const enforcedArgs = [];
|
||||
|
||||
const configuredMaxWorkers = resolveMaxWorkers();
|
||||
if (configuredMaxWorkers && !hasForwardedArg("--maxWorkers")) {
|
||||
enforcedArgs.push(`--maxWorkers=${configuredMaxWorkers}`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
activeChild = spawn(process.execPath, [vitestCliPath, "run", ...files, ...enforcedArgs, ...args.forwardedArgs], {
|
||||
cwd: workingDirectory,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
activeChild.on("close", (code) => {
|
||||
activeChild = null;
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
console.error(`Received ${signal}. Stopping active Vitest fast run...`);
|
||||
|
||||
if (activeChild?.pid) {
|
||||
if (process.platform === "win32") {
|
||||
spawn("taskkill", ["/PID", String(activeChild.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
} else {
|
||||
activeChild.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.access(vitestCliPath);
|
||||
|
||||
const serialAllowlist = await readSerialAllowlist();
|
||||
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
|
||||
const fastFiles = discoveredFiles.filter((absolutePath) => {
|
||||
const relativePath = normalizePathSegments(path.relative(workingDirectory, absolutePath));
|
||||
return !serialAllowlist.has(relativePath);
|
||||
});
|
||||
const selectedFiles = applyFilters(fastFiles, args.specFilters);
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
console.log("No parallel-safe unit spec files matched the provided filters.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Running ${selectedFiles.length} parallel-safe unit spec file(s); ${serialAllowlist.size} spec file(s) remain in the serial allowlist.`
|
||||
);
|
||||
|
||||
const code = await runVitest(selectedFiles);
|
||||
if (code !== 0) {
|
||||
process.exitCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+46
-25
@@ -1,41 +1,51 @@
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, onMounted, watch } from "vue";
|
||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
// Set the favicon to the /favicon.ico file
|
||||
import { useHead } from "@vueuse/head";
|
||||
import favicon from '@/assets/favicon.ico';
|
||||
import {useRoute} from "vue-router";
|
||||
import LayoutV2 from "@/components/page/wrappers/LayoutV2.vue";
|
||||
import favicon from "@/assets/favicon.ico";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
|
||||
import {
|
||||
releaseChannelSwitchedStatus,
|
||||
releaseChannelUnavailableStatus,
|
||||
startReleaseChannelAvailabilityClock,
|
||||
stopReleaseChannelAvailabilityClock,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
|
||||
const route = useRoute();
|
||||
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(() =>
|
||||
import("@/components/release/ReleaseChannelUnavailable.vue")
|
||||
);
|
||||
const ReleaseChannelSwitched = defineAsyncComponent(() => import("@/components/release/ReleaseChannelSwitched.vue"));
|
||||
|
||||
const routeTitleI18nKeys = {
|
||||
"about-us": ["header.about_us"],
|
||||
"book-a-demo": ["about_us.solutions.operator.book_demo"],
|
||||
"customer-creation": ["header.customer_creation"],
|
||||
"login": ["header.customer_login"],
|
||||
"subuserlogin": ["header.customer_login"],
|
||||
"adminlogin": ["header.operator_login"],
|
||||
"guestHome": ["guest.home.title"],
|
||||
"PwaDownload": ["guest.pwa.title"],
|
||||
login: ["header.customer_login"],
|
||||
subuserlogin: ["header.customer_login"],
|
||||
adminlogin: ["header.operator_login"],
|
||||
guestHome: ["guest.home.title"],
|
||||
PwaDownload: ["guest.pwa.title"],
|
||||
"connectivity-issue": ["connectivity.title"],
|
||||
"selfservestudio": ["admin.self_serve.title"]
|
||||
selfservestudio: ["admin.self_serve.title"],
|
||||
};
|
||||
|
||||
const toReadableTitle = (value) => {
|
||||
if (!value) return "";
|
||||
return value
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
const resolveRouteTitle = () => {
|
||||
@@ -49,7 +59,7 @@ const resolveRouteTitle = () => {
|
||||
}
|
||||
|
||||
const routeName = typeof route.name === "string" ? route.name : "";
|
||||
const knownKeys = routeName ? (routeTitleI18nKeys[routeName] || []) : [];
|
||||
const knownKeys = routeName ? routeTitleI18nKeys[routeName] || [] : [];
|
||||
for (const key of knownKeys) {
|
||||
if (te(key)) return t(key);
|
||||
}
|
||||
@@ -63,6 +73,13 @@ const updateDocumentTitle = () => {
|
||||
};
|
||||
|
||||
const shouldRenderRequestQueueProgress = computed(() => hasStoredSessionToken());
|
||||
const shouldRenderReleaseChannelUnavailable = computed(() => releaseChannelUnavailableStatus.value.shouldBlock);
|
||||
const shouldRenderReleaseChannelSwitched = computed(
|
||||
() => !shouldRenderReleaseChannelUnavailable.value && releaseChannelSwitchedStatus.value.shouldShow
|
||||
);
|
||||
const shouldHideGlobalOverlays = computed(
|
||||
() => shouldRenderReleaseChannelUnavailable.value || shouldRenderReleaseChannelSwitched.value
|
||||
);
|
||||
|
||||
const initiateStoredSession = async () => {
|
||||
if (!hasStoredSessionToken()) {
|
||||
@@ -78,19 +95,24 @@ const initiateStoredSession = async () => {
|
||||
};
|
||||
|
||||
useHead({
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: favicon }
|
||||
]
|
||||
link: [{ rel: "icon", type: "image/x-icon", href: favicon }],
|
||||
});
|
||||
onMounted(() => {
|
||||
startReleaseChannelAvailabilityClock();
|
||||
void initiateStoredSession();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopReleaseChannelAvailabilityClock();
|
||||
});
|
||||
|
||||
watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutV2 v-if="route.meta?.template === 'with-header'">
|
||||
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
|
||||
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
|
||||
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
|
||||
<router-view />
|
||||
</LayoutV2>
|
||||
<main v-else-if="route.meta?.template === 'clear-main'">
|
||||
@@ -100,13 +122,12 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
||||
<header></header>
|
||||
<main>
|
||||
<DefaultPageWrapper>
|
||||
<VersionCheck/>
|
||||
<router-view />
|
||||
</DefaultPageWrapper>
|
||||
</main>
|
||||
</template>
|
||||
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress" />
|
||||
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,10 +74,7 @@ watch(locale, (newLocale) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background-color: transparent;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -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,26 +1,15 @@
|
||||
<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>
|
||||
<div>
|
||||
<PageTitle :title="t('superuser.pages.departments.title')" :subtitle="t('superuser.pages.departments.subtitle')">
|
||||
<PageTitle :title="t('common.departments')" :subtitle="t('superuser.pages.departments.subtitle')">
|
||||
<template #buttons>
|
||||
<button class="button is-dark" @click="showCreateDepartmentForm">
|
||||
<span class="icon">
|
||||
@@ -36,4 +25,4 @@ const redirect = (path) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@ const { t } = useI18n();
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageTitle :title="t('superuser.pages.products.title')" :subtitle="t('superuser.pages.products.subtitle')">
|
||||
<PageTitle :title="t('common.products')" :subtitle="t('superuser.pages.products.subtitle')">
|
||||
<template #buttons>
|
||||
<button class="button is-dark" @click="SessionUser.objects.products.showCreateObjectForm(loadList)">
|
||||
<span class="icon">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ const props = defineProps({
|
||||
hasBorder: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
headerTestId: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
});
|
||||
/**
|
||||
@@ -95,7 +99,7 @@ watch(() => props.forceState, (newVal) => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Default slot for header -->
|
||||
<div class="card-header is-radiusless" @click="toggle" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header && !props.hideHeader">
|
||||
<div class="card-header is-radiusless" @click="toggle" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header && !props.hideHeader" :data-testid="props.headerTestId || undefined">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<!-- Default slot for any content -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -119,9 +119,25 @@ const formatDateInputValue = (date: Date) => {
|
||||
return "";
|
||||
}
|
||||
|
||||
return date.toISOString().split("T")[0];
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const parseDateInputValue = (value: string) => {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match) {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
};
|
||||
|
||||
const isSameDateInputValue = (left: Date, right: Date) => (
|
||||
formatDateInputValue(left) === formatDateInputValue(right)
|
||||
);
|
||||
|
||||
const updateSelection = () => {
|
||||
emits("update:selection", { startDate: startDate.value, endDate: endDate.value });
|
||||
props.onSelectionChange(startDate.value, endDate.value);
|
||||
@@ -177,25 +193,38 @@ const isSelectionValid = computed(() => (
|
||||
));
|
||||
|
||||
const handleStartDateChange = (event) => {
|
||||
const newDate = new Date(event.target.value);
|
||||
const newDate = parseDateInputValue(event.target.value);
|
||||
if (Number.isNaN(newDate.getTime())) {
|
||||
console.warn("Invalid start date:", event.target.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSameDateInputValue(newDate, startDate.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyDateRange(newDate, endDate.value);
|
||||
};
|
||||
|
||||
const handleEndDateChange = (event) => {
|
||||
const newDate = new Date(event.target.value);
|
||||
const newDate = parseDateInputValue(event.target.value);
|
||||
if (Number.isNaN(newDate.getTime())) {
|
||||
console.warn("Invalid end date:", event.target.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSameDateInputValue(newDate, endDate.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyDateRange(startDate.value, newDate);
|
||||
};
|
||||
|
||||
const getFullMonthRange = (year: number, month: number): DateRange => ({
|
||||
startDate: new Date(year, month - 1, 1, 0, 0, 0, 0),
|
||||
endDate: new Date(year, month, 0, 23, 59, 59, 999),
|
||||
});
|
||||
|
||||
const setMonth = (month: number) => {
|
||||
if (month < 1 || month > 12) {
|
||||
console.warn("Invalid month selection:", month);
|
||||
@@ -203,9 +232,8 @@ const setMonth = (month: number) => {
|
||||
}
|
||||
|
||||
const year = startDate.value.getFullYear();
|
||||
const startOfMonth = new Date(year, month - 1, 1);
|
||||
const endOfMonth = new Date(year, month, 0);
|
||||
applyDateRange(startOfMonth, endOfMonth);
|
||||
const range = getFullMonthRange(year, month);
|
||||
applyDateRange(range.startDate, range.endDate);
|
||||
};
|
||||
|
||||
const handleMonthChange = (event) => {
|
||||
@@ -217,6 +245,10 @@ const handleMonthChange = (event) => {
|
||||
setMonth(month);
|
||||
};
|
||||
|
||||
const setCurrentSelectionEntireMonth = () => {
|
||||
setMonth(currentMonthSelection.value);
|
||||
};
|
||||
|
||||
const handleYearChange = (event) => {
|
||||
const year = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(year)) {
|
||||
@@ -307,24 +339,14 @@ const shortcuts = computed<Shortcut[]>(() => {
|
||||
label: SessionUser.objects.global.language.text.this_month,
|
||||
getRange: () => {
|
||||
const today = new Date();
|
||||
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return {
|
||||
startDate: new Date(firstDayOfThisMonth.setHours(23, 59, 59, 999)),
|
||||
endDate: new Date(lastDayOfThisMonth.setHours(23, 59, 59, 999)),
|
||||
};
|
||||
return getFullMonthRange(today.getFullYear(), today.getMonth() + 1);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.last_month,
|
||||
getRange: () => {
|
||||
const today = new Date();
|
||||
const firstDayOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
const lastDayOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
return {
|
||||
startDate: new Date(firstDayOfLastMonth.setHours(23, 59, 59, 999)),
|
||||
endDate: new Date(lastDayOfLastMonth.setHours(23, 59, 59, 999)),
|
||||
};
|
||||
return getFullMonthRange(today.getFullYear(), today.getMonth());
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -346,16 +368,7 @@ const shortcuts = computed<Shortcut[]>(() => {
|
||||
label: SessionUser.objects.global.language.text.same_month_last_year,
|
||||
getRange: () => {
|
||||
const today = new Date();
|
||||
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
const firstDayOfLastYearSameMonth = new Date(firstDayOfThisMonth);
|
||||
firstDayOfLastYearSameMonth.setFullYear(firstDayOfThisMonth.getFullYear() - 1);
|
||||
const lastDayOfLastYearSameMonth = new Date(lastDayOfThisMonth);
|
||||
lastDayOfLastYearSameMonth.setFullYear(lastDayOfThisMonth.getFullYear() - 1);
|
||||
return {
|
||||
startDate: new Date(firstDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
|
||||
endDate: new Date(lastDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
|
||||
};
|
||||
return getFullMonthRange(today.getFullYear() - 1, today.getMonth() + 1);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -402,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>
|
||||
@@ -419,6 +432,7 @@ const handleShortcutSelection = (event) => {
|
||||
class="input"
|
||||
data-testid="date-period-start"
|
||||
:value="formatDateInputValue(startDate)"
|
||||
@input="handleStartDateChange"
|
||||
@change="handleStartDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
@@ -434,6 +448,7 @@ const handleShortcutSelection = (event) => {
|
||||
class="input"
|
||||
data-testid="date-period-end"
|
||||
:value="formatDateInputValue(endDate)"
|
||||
@input="handleEndDateChange"
|
||||
@change="handleEndDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
@@ -494,9 +509,10 @@ const handleShortcutSelection = (event) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-period-selector__mobile-actions" v-if="visibility.showUpdateButton">
|
||||
<div class="date-period-selector__mobile-actions">
|
||||
<slot name="right"></slot>
|
||||
<button
|
||||
v-if="visibility.showUpdateButton"
|
||||
class="button is-primary is-fullwidth"
|
||||
type="button"
|
||||
@click="updateSelection"
|
||||
@@ -516,6 +532,7 @@ const handleShortcutSelection = (event) => {
|
||||
class="input"
|
||||
data-testid="date-period-start"
|
||||
:value="formatDateInputValue(startDate)"
|
||||
@input="handleStartDateChange"
|
||||
@change="handleStartDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
@@ -530,6 +547,7 @@ const handleShortcutSelection = (event) => {
|
||||
class="input"
|
||||
data-testid="date-period-end"
|
||||
:value="formatDateInputValue(endDate)"
|
||||
@input="handleEndDateChange"
|
||||
@change="handleEndDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
@@ -648,7 +666,7 @@ const handleShortcutSelection = (event) => {
|
||||
iconPack="fas"
|
||||
has-icon
|
||||
>
|
||||
Selection is not an entire month <span class="has-text-weight-bold is-clickable" @click="setMonth(currentMonthSelection)">Click to set entire month</span>.
|
||||
Selection is not an entire month <span class="has-text-weight-bold is-clickable" data-testid="date-period-set-entire-month" @click="setCurrentSelectionEntireMonth">Click to set entire month</span>.
|
||||
When a partial month selection is made, it may not accurately represent the intended time period. Consider selecting an entire month for more precise results.
|
||||
</b-message>
|
||||
</div>
|
||||
|
||||
@@ -28,10 +28,10 @@ if (departments.value.length === 0) {
|
||||
<th>{{ $t('objects.columns.department') }}</th>
|
||||
<th>{{ $t('tables.bookings.reg_tractor') }}</th>
|
||||
<th>{{ $t('tables.bookings.reg_trailer') }}</th>
|
||||
<th>{{ $t('objects.columns.reference') }}</th>
|
||||
<th>{{ $t('common.reference') }}</th>
|
||||
<th>{{ $t('objects.columns.notes') }}</th>
|
||||
<th>{{ $t('objects.columns.date') }}</th>
|
||||
<th>{{ $t('objects.columns.status') }}</th>
|
||||
<th>{{ $t('common.date') }}</th>
|
||||
<th>{{ $t('common.status') }}</th>
|
||||
<th>{{ $t('objects.bookings.columns.contact_email') }}</th>
|
||||
<th>{{ $t('objects.bookings.columns.wash_certificate_email') }}</th>
|
||||
<th>{{ $t('tables.actions') }}</th>
|
||||
@@ -53,7 +53,7 @@ if (departments.value.length === 0) {
|
||||
<td>{{ booking.washCertificateEmail }}</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('global.show') }}</button>
|
||||
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('common.show') }}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -94,6 +94,7 @@ const modules = computed(() => [
|
||||
},
|
||||
count: 0,
|
||||
hasSwitch: true,
|
||||
switchLoading: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
|
||||
switchValue: selfServeEnabled,
|
||||
switchDisabled: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
|
||||
onToggle: async (newValue) => {
|
||||
@@ -118,8 +119,8 @@ const modules = computed(() => [
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Self-Serve Studio',
|
||||
description: 'Configure questions, conditions, rules and tasks',
|
||||
name: 'Døgnvask konfiguration',
|
||||
description: 'Gå til konfiguration af Døgnvask',
|
||||
icon: "fas fa-project-diagram",
|
||||
onClick: () => {
|
||||
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
|
||||
@@ -225,9 +226,17 @@ setInterval(() => {
|
||||
<i :class="module.icon"></i>
|
||||
</span>
|
||||
</div>
|
||||
<p class="card-header-title">{{ module.name }}</p>
|
||||
<div class="is-pulled-right card-header-title pr-0">
|
||||
<p class="card-header-title department-module-card__header-title">{{ module.name }}</p>
|
||||
<div class="department-module-card__actions" data-testid="department-module-card-actions">
|
||||
<span class="button is-small is-dark department-module-card__count" @click.stop="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
|
||||
<span
|
||||
v-else-if="module?.switchLoading"
|
||||
class="tag is-rounded is-small department-module-card__state-loading"
|
||||
data-testid="department-module-self-serve-state-loading"
|
||||
role="status"
|
||||
:aria-label="t('global.loading')"
|
||||
@click.stop
|
||||
></span>
|
||||
<span class="button is-small is-outlined department-module-card__switch" v-else-if="module?.hasSwitch" @click.stop>
|
||||
<b-switch
|
||||
size="is-small"
|
||||
@@ -253,19 +262,72 @@ setInterval(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.department-module-card .card-header-title {
|
||||
.department-module-card__header-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.department-module-card__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.department-module-card__count,
|
||||
.department-module-card__switch {
|
||||
.department-module-card__switch,
|
||||
.department-module-card__state-loading {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.department-module-card__state-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.5rem;
|
||||
min-height: 1.25rem;
|
||||
padding: 0 0.5rem !important;
|
||||
background-color: #e5e7eb !important;
|
||||
border: 1px solid #d1d5db !important;
|
||||
color: transparent !important;
|
||||
background-image:
|
||||
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem),
|
||||
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem),
|
||||
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.4rem 0.4rem;
|
||||
background-position:
|
||||
calc(50% - 0.5rem) 50%,
|
||||
50% 50%,
|
||||
calc(50% + 0.5rem) 50%;
|
||||
animation: department-module-state-loading-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes department-module-state-loading-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.68;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.department-module-card__state-loading {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.department-module-card__title {
|
||||
display: none;
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ const hasUpdatePermission = () => {
|
||||
:editFunction="su_object.showEditObjectFieldForm"
|
||||
column="enabled"
|
||||
:parse-function="(value) => {
|
||||
return value ? t('global.yes') : t('global.no');
|
||||
return value ? t('common.yes') : t('common.no');
|
||||
}"
|
||||
:permission-check-function="hasUpdatePermission"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -246,7 +246,7 @@ const getScanBadges = (scan) => {
|
||||
if (!scan.seen_before) {
|
||||
badges.push({
|
||||
key: 'unknown',
|
||||
text: t('admin.pos.status_unknown'),
|
||||
text: t('common.new'),
|
||||
className: 'pos-scan-badge--unknown',
|
||||
});
|
||||
}
|
||||
@@ -560,7 +560,7 @@ setTimeout(() => {
|
||||
<span class="icon">
|
||||
<i class="fas fa-redo"></i>
|
||||
</span>
|
||||
<span>{{ $t('admin.pos.try_again') }}</span>
|
||||
<span>{{ $t('common.retry') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
import { showEditOrderItemForm } from "@/components/forms/superUser/editOrderItemForm.vue";
|
||||
import { removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import { computed, ref, watch, useSlots } from "vue";
|
||||
@@ -26,6 +27,11 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(["update:orderItems", "onDestroyItem"]);
|
||||
const route = useRoute();
|
||||
const highlightOrderItemId = computed(() => {
|
||||
const parsed = Number.parseInt(String(route.query.highlightOrderItem || ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
});
|
||||
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
@@ -69,6 +75,10 @@ const handleCustomerWishesFieldSaved = async (fieldKey) => {
|
||||
|
||||
await loadOrder();
|
||||
};
|
||||
|
||||
const handleOrderNoteSaved = (savedValue) => {
|
||||
order_notes.value = savedValue;
|
||||
};
|
||||
// Check if the size is large
|
||||
const isLarge = props.isLarge !== undefined ? props.isLarge : false;
|
||||
const isOrderDetailVariant = computed(() => props.variant === "order-detail");
|
||||
@@ -502,6 +512,7 @@ const deleteOrderItem = async (orderItemId) => {
|
||||
:total-price="totalPrice"
|
||||
:show-actions="Boolean(editPossible)"
|
||||
:can-edit-order-items="canEditOrderItems"
|
||||
:highlight-order-item-id="highlightOrderItemId"
|
||||
@edit="openOrderItemEditor"
|
||||
@delete="deleteOrderItem"
|
||||
/>
|
||||
@@ -518,6 +529,7 @@ const deleteOrderItem = async (orderItemId) => {
|
||||
:total-price="totalPrice"
|
||||
:show-actions="Boolean(editPossible)"
|
||||
:can-edit-order-items="canEditOrderItems"
|
||||
:highlight-order-item-id="highlightOrderItemId"
|
||||
@edit="openOrderItemEditor"
|
||||
@delete="deleteOrderItem"
|
||||
/>
|
||||
@@ -528,14 +540,14 @@ const deleteOrderItem = async (orderItemId) => {
|
||||
<table v-if="false" class="table is-fullwidth pos-order-items__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("admin.pos.product") }}</th>
|
||||
<th>{{ $t("common.product") }}</th>
|
||||
<th style="width: 20%; text-align: right">{{ $t("admin.pos.price_dkk") }}</th>
|
||||
<th
|
||||
v-if="editPossible"
|
||||
class="pos-order-items__actions-header"
|
||||
:style="{ width: isOrderDetailVariant ? '18%' : '15%' }"
|
||||
>
|
||||
{{ isOrderDetailVariant ? $t("global.actions") : "" }}
|
||||
{{ isOrderDetailVariant ? $t("common.actions") : "" }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -920,7 +932,12 @@ const deleteOrderItem = async (orderItemId) => {
|
||||
data-testid="pos-order-metadata-note"
|
||||
>
|
||||
<p class="pos-order-metadata-label">{{ $t("admin.pos.order_note") }}</p>
|
||||
<POSOrderNote v-bind:order_id="activeOrderId" v-bind:loadOrder="loadOrder" v-bind:note="order_notes" />
|
||||
<POSOrderNote
|
||||
v-bind:order_id="activeOrderId"
|
||||
v-bind:loadOrder="loadOrder"
|
||||
v-bind:note="order_notes"
|
||||
v-bind:onSaved="handleOrderNoteSaved"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentB
|
||||
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const emit = defineEmits(["update:activeTab"]);
|
||||
|
||||
const panel_tabs = ref([
|
||||
{
|
||||
@@ -151,6 +152,7 @@ const rootClasses = computed(() => ({
|
||||
}));
|
||||
const isSidebarVariant = computed(() => props.variant === "sidebar");
|
||||
const isRulesTabActive = computed(() => panel_tabs.value[1]?.active === true);
|
||||
const activeTabKey = computed(() => panel_tabs.value.find((tab) => tab.active)?.key ?? "details");
|
||||
|
||||
const setActiveTab = (selectedIndex) => {
|
||||
panel_tabs.value.forEach((tab, index) => {
|
||||
@@ -171,6 +173,14 @@ const hasAttribute = (prop) => {
|
||||
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
|
||||
};
|
||||
|
||||
watch(
|
||||
activeTabKey,
|
||||
(tabKey) => {
|
||||
emit("update:activeTab", tabKey);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
isRulesTabActive,
|
||||
(isActive) => {
|
||||
@@ -255,52 +265,62 @@ const customer_data_has_empty_details = () => {
|
||||
/>
|
||||
<!-- Attributes -->
|
||||
<RequiresPermission permission="list_customer_attributes">
|
||||
<div
|
||||
class="panel-block pos-selected-customer__row"
|
||||
v-if="panel_tabs[1].active"
|
||||
v-for="attribute in attributes"
|
||||
:key="attribute.attribute"
|
||||
>
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="attribute.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span
|
||||
class="pos-selected-customer__label"
|
||||
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>{{ attribute.name }}</span
|
||||
<template v-if="panel_tabs[1].active">
|
||||
<div
|
||||
v-for="attribute in attributes"
|
||||
:key="attribute.attribute"
|
||||
class="panel-block pos-selected-customer__row"
|
||||
data-testid="pos-customer-rule-row"
|
||||
:data-rule-attribute="attribute.attribute"
|
||||
>
|
||||
<span class="pos-selected-customer__value">
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="attribute.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span
|
||||
v-if="hasAttribute(attribute.prop)"
|
||||
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
|
||||
class="pos-selected-customer__label"
|
||||
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>{{ attribute.name }}</span
|
||||
>
|
||||
<span class="field">
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
checked="checked"
|
||||
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
<span class="pos-selected-customer__value">
|
||||
<span
|
||||
v-if="hasAttribute(attribute.prop)"
|
||||
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
|
||||
>
|
||||
<span class="field">
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
checked="checked"
|
||||
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
|
||||
<span class="field">
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
|
||||
<span class="field">
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots['rules-footer']"
|
||||
class="pos-selected-customer__rules-footer"
|
||||
data-testid="pos-customer-rules-footer"
|
||||
>
|
||||
<slot name="rules-footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</RequiresPermission>
|
||||
<!-- Shortcuts -->
|
||||
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
|
||||
@@ -425,6 +445,14 @@ const customer_data_has_empty_details = () => {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pos-selected-customer__rules-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #eef3f8;
|
||||
}
|
||||
|
||||
.pos-selected-customer--sidebar .pos-selected-customer__title,
|
||||
.pos-selected-customer--order-detail .pos-selected-customer__title {
|
||||
font-size: 1.05rem;
|
||||
|
||||
@@ -121,7 +121,7 @@ const createCustomerWishField = ({
|
||||
|
||||
const referenceField = createCustomerWishField({
|
||||
key: "reference",
|
||||
label: computed(() => t("pos.order.reference")),
|
||||
label: computed(() => t("common.reference")),
|
||||
source: () => props.reference,
|
||||
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
|
||||
testIdBase: "pos-order-customer-wishes-reference",
|
||||
|
||||
@@ -16,6 +16,10 @@ const props = defineProps({
|
||||
loadOrder: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
onSaved: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,6 +33,11 @@ const autosave = useOrderMetadataAutosave({
|
||||
await SessionUser.objects.orders.set.notes(props.order_id, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (savedValue, requestedValue) => {
|
||||
if (typeof props.onSaved === "function") {
|
||||
await props.onSaved(savedValue, requestedValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const previewValue = computed(() => autosave.draft.value);
|
||||
|
||||
@@ -186,7 +186,7 @@ watch(normalizedOrderId, async (nextOrderId, previousOrderId) => {
|
||||
class="pos-inline-tag"
|
||||
data-testid="pos-order-inline-created"
|
||||
>
|
||||
<span class="pos-inline-tag__label">{{ $t('pos.order.created') }}:</span>
|
||||
<span class="pos-inline-tag__label">{{ $t('common.created') }}:</span>
|
||||
<span class="pos-inline-tag__value">{{ resolvedCreatedAt }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
highlightOrderItemId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['edit', 'delete']);
|
||||
@@ -42,6 +46,7 @@ const getRowClass = (row) => {
|
||||
'pos-order-items__row--related': row.isRelated,
|
||||
'pos-order-items__row--group-parent': row.hasRelatedItems,
|
||||
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
|
||||
'pos-order-items__row--highlight': Number(row?.orderItem?.id) === Number(props.highlightOrderItemId),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -102,7 +107,7 @@ const getActionsCellAttrs = () => ({
|
||||
>
|
||||
<b-table-column
|
||||
field="product"
|
||||
:label="$t('admin.pos.product')"
|
||||
:label="$t('common.product')"
|
||||
v-slot="tableProps"
|
||||
>
|
||||
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
|
||||
@@ -187,7 +192,7 @@ const getActionsCellAttrs = () => ({
|
||||
<b-table-column
|
||||
v-if="showActions"
|
||||
field="actions"
|
||||
:label="$t('global.actions')"
|
||||
:label="$t('common.actions')"
|
||||
custom-key="actions"
|
||||
header-class="pos-order-items__actions-header"
|
||||
:th-attrs="getActionsHeaderAttrs"
|
||||
@@ -293,6 +298,11 @@ const getActionsCellAttrs = () => ({
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table tbody tr.pos-order-items__row--highlight td) {
|
||||
background: #fff3cd !important;
|
||||
box-shadow: inset 3px 0 0 #ffdd57;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table tfoot td) {
|
||||
font-weight: 600;
|
||||
border-top: 1px solid #dfe7f1;
|
||||
|
||||
@@ -116,12 +116,12 @@ const saveChanges = async () => {
|
||||
>
|
||||
<div class="pos-order-item-edit-modal__header">
|
||||
<p class="title is-5 mb-1">{{ t('admin.pos.edit') }}</p>
|
||||
<p class="is-size-7 has-text-grey mb-0">{{ t('admin.pos.product') }} #{{ orderItem.id }}</p>
|
||||
<p class="is-size-7 has-text-grey mb-0">{{ t('common.product') }} #{{ orderItem.id }}</p>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<label class="label" for="pos-order-item-edit-name">{{ t('admin.pos.product') }}</label>
|
||||
<label class="label" for="pos-order-item-edit-name">{{ t('common.product') }}</label>
|
||||
<input
|
||||
id="pos-order-item-edit-name"
|
||||
class="input"
|
||||
@@ -133,7 +133,7 @@ const saveChanges = async () => {
|
||||
</div>
|
||||
|
||||
<div class="column is-12-mobile is-6-tablet">
|
||||
<label class="label" for="pos-order-item-edit-price">{{ t('global.price') }}</label>
|
||||
<label class="label" for="pos-order-item-edit-price">{{ t('common.price') }}</label>
|
||||
<input
|
||||
id="pos-order-item-edit-price"
|
||||
v-model="form.price"
|
||||
@@ -147,7 +147,7 @@ const saveChanges = async () => {
|
||||
</div>
|
||||
|
||||
<div class="column is-12-mobile is-6-tablet">
|
||||
<label class="label" for="pos-order-item-edit-quantity">{{ t('global.quantity') }}</label>
|
||||
<label class="label" for="pos-order-item-edit-quantity">{{ t('common.quantity') }}</label>
|
||||
<input
|
||||
id="pos-order-item-edit-quantity"
|
||||
v-model="form.quantity"
|
||||
@@ -173,7 +173,7 @@ const saveChanges = async () => {
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<label class="label" for="pos-order-item-edit-reference">{{ t('objects.columns.reference') }}</label>
|
||||
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
|
||||
<input
|
||||
id="pos-order-item-edit-reference"
|
||||
v-model="form.reference"
|
||||
|
||||
@@ -21,6 +21,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
highlightOrderItemId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['edit', 'delete']);
|
||||
@@ -44,6 +48,7 @@ const getRowClass = (row) => {
|
||||
'pos-order-items__row--related': row.isRelated,
|
||||
'pos-order-items__row--group-parent': row.hasRelatedItems,
|
||||
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
|
||||
'pos-order-items__row--highlight': Number(row?.orderItem?.id) === Number(props.highlightOrderItemId),
|
||||
};
|
||||
};
|
||||
</script>
|
||||
@@ -59,7 +64,7 @@ const getRowClass = (row) => {
|
||||
>
|
||||
<b-table-column
|
||||
field="product"
|
||||
:label="$t('admin.pos.product')"
|
||||
:label="$t('common.product')"
|
||||
v-slot="tableProps"
|
||||
>
|
||||
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
|
||||
@@ -156,7 +161,9 @@ const getRowClass = (row) => {
|
||||
>
|
||||
<div class="pos-order-items__action-cell">
|
||||
<div class="buttons is-narrow">
|
||||
<ActionSettingsWheelButton>
|
||||
<ActionSettingsWheelButton
|
||||
:flag-target="{ target_type: 'order_item', target_id: tableProps.row.orderItem.id }"
|
||||
>
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
:label="$t('admin.pos.delete')"
|
||||
@@ -232,6 +239,11 @@ const getRowClass = (row) => {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.pos-order-items__default-table :deep(.table tbody tr.pos-order-items__row--highlight td) {
|
||||
background: #fff3cd !important;
|
||||
box-shadow: inset 3px 0 0 #ffdd57;
|
||||
}
|
||||
|
||||
.pos-order-items__default-table :deep(.table tfoot td) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ const props = defineProps({
|
||||
console.warn('setItemValue function not provided', orderItem, column, value);
|
||||
},
|
||||
},
|
||||
highlightOrderItemId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const calculateTax = (price) => {
|
||||
@@ -76,9 +80,9 @@ const getOrderItemBasePrice = (orderItem) => {
|
||||
<tr>
|
||||
<th>{{ $t('tables.products.name') }}</th>
|
||||
<th>{{ $t('global.note') }}</th>
|
||||
<th>{{ $t('objects.columns.reference') }}</th>
|
||||
<th>{{ $t('global.quantity') }}</th>
|
||||
<th style="text-align: right;">{{ $t('global.price') }}</th>
|
||||
<th>{{ $t('common.reference') }}</th>
|
||||
<th>{{ $t('common.quantity') }}</th>
|
||||
<th style="text-align: right;">{{ $t('common.price') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -88,7 +92,13 @@ const getOrderItemBasePrice = (orderItem) => {
|
||||
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="orderItem in props.orderItems" :key="orderItem.id" v-else>
|
||||
<tr
|
||||
v-for="orderItem in props.orderItems"
|
||||
:key="orderItem.id"
|
||||
v-else
|
||||
:id="`order-item-${orderItem.id}`"
|
||||
:class="{ 'order-item-highlight': Number(orderItem.id) === Number(props.highlightOrderItemId) }"
|
||||
>
|
||||
<td>{{ getOrderItemProductName(orderItem) }}</td>
|
||||
<td>
|
||||
<input type="text" v-if="props.allowEdit" v-model="orderItem.notes" @input="props.setItemValue(orderItem, 'notes', orderItem.notes)" class="input is-small" />
|
||||
@@ -111,7 +121,7 @@ const getOrderItemBasePrice = (orderItem) => {
|
||||
<tfoot>
|
||||
<!-- Tax -->
|
||||
<tr v-if="props.taxPercentage > 0">
|
||||
<td colspan="4">{{ $t('global.tax') }} ({{ props.taxPercentage }}%)</td>
|
||||
<td colspan="4">{{ $t('common.tax') }} ({{ props.taxPercentage }}%)</td>
|
||||
<td style="text-align: right;" v-if="props.isLoading">
|
||||
<!-- Skeleton-lines 1 row -->
|
||||
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
|
||||
@@ -147,5 +157,8 @@ const getOrderItemBasePrice = (orderItem) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.order-item-highlight > td {
|
||||
background: #fff3cd !important;
|
||||
box-shadow: inset 3px 0 0 #ffdd57;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -294,7 +294,7 @@ const uploadOrderAttachmentFile = async (file) => {
|
||||
} catch (error) {
|
||||
console.error("Error uploading order attachment:", error);
|
||||
Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.error"),
|
||||
title: t("common.error"),
|
||||
text: t("admin.pos.attachments_upload_error"),
|
||||
icon: "error",
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ const { t } = useI18n();
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import OrderAttachmentsActionButton from "@/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue";
|
||||
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
|
||||
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
|
||||
|
||||
const props = defineProps({
|
||||
orders: {
|
||||
@@ -44,13 +45,19 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
invoicePeriodFlags: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
|
||||
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { orderBy, orderDirection, setOrder, loadList } from "@/components/pagination/paginatedList.vue";
|
||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
||||
import InvoiceMultipleCollectionsModal from "@/components/displays/modals/InvoiceMultipleCollectionsModal.vue";
|
||||
@@ -397,6 +404,20 @@ const tableHeaders = ref([
|
||||
},
|
||||
]);
|
||||
|
||||
const isTableHeaderVisible = (header) => {
|
||||
return header.visible === undefined || header.visible(null) === true;
|
||||
};
|
||||
|
||||
const invoiceCollectionSummaryColumnSpan = computed(() => {
|
||||
const invoiceControlColumns =
|
||||
(props.invoiceView && props.allowSelectMultiple ? 1 : 0) + (props.invoiceView ? 1 : 0);
|
||||
const leadingOrderColumns = tableHeaders.value.filter(
|
||||
(header) => isTableHeaderVisible(header) && !["total_net_amount", "actions"].includes(header.name)
|
||||
).length;
|
||||
|
||||
return invoiceControlColumns + leadingOrderColumns;
|
||||
});
|
||||
|
||||
const active_dropdown_object_ids = ref([]);
|
||||
const toggleDropdown = (object) => {
|
||||
// Check if the object is already in the active dropdown list
|
||||
@@ -471,6 +492,18 @@ const invoiceCollectionQueueSuccess = invoiceQueue.invoiceCollectionQueueSuccess
|
||||
const invoiceCollectionQueue = invoiceQueue.invoiceCollectionQueue;
|
||||
const collectionQueueLog = invoiceQueue.collectionQueueLog;
|
||||
const isInvoiceMultipleCollectionsModalOpen = ref(false);
|
||||
const isSuperuserInvoicesRoute = computed(() => (
|
||||
route.path === "/superuser/invoices" || route.path.startsWith("/superuser/invoices/")
|
||||
));
|
||||
const legacyInvoiceProgressCount = computed(() => (
|
||||
invoiceCollectionQueueSuccess.value.length
|
||||
+ invoiceCollectionQueueFailed.value.length
|
||||
+ invoiceCollectionQueueInProgress.value.length
|
||||
+ invoiceCollectionQueue.value.length
|
||||
));
|
||||
const shouldShowLegacyInvoiceProgressBar = computed(() => {
|
||||
return !isSuperuserInvoicesRoute.value && legacyInvoiceProgressCount.value > 0;
|
||||
});
|
||||
const isInvoiceQueueBusy = computed(() => {
|
||||
return invoiceCollectionQueue.value.length > 0 || invoiceCollectionQueueInProgress.value.length > 0;
|
||||
});
|
||||
@@ -666,6 +699,66 @@ const canSeePoField = () => {
|
||||
//return (SessionUser.hasAttribute('usePONumbers') || SessionUser.hasPermission('department'))
|
||||
};
|
||||
|
||||
const sortInvoicePeriodFlags = (flags = []) => [...flags].sort((left, right) => {
|
||||
const sourceOrder = { manual: 0, automatic: 1 };
|
||||
const leftSource = sourceOrder[left?.source] ?? 99;
|
||||
const rightSource = sourceOrder[right?.source] ?? 99;
|
||||
if (leftSource !== rightSource) {
|
||||
return leftSource - rightSource;
|
||||
}
|
||||
|
||||
return String(left?.created_at || left?.fingerprint || "").localeCompare(
|
||||
String(right?.created_at || right?.fingerprint || "")
|
||||
);
|
||||
});
|
||||
const isActiveInvoicePeriodFlag = (flag) => String(flag?.status || "active") === "active";
|
||||
const getOrderLineFlags = (order) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
|
||||
if (!isActiveInvoicePeriodFlag(flag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetType = String(flag?.target_type || "");
|
||||
if (!["order", "order_field"].includes(targetType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flagOrderId = Number(flag?.order_id || flag?.target_id || 0);
|
||||
return flagOrderId > 0 && flagOrderId === Number(order?.id || 0);
|
||||
}));
|
||||
const getOrderItemLineFlags = (order) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
|
||||
if (!isActiveInvoicePeriodFlag(flag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetType = String(flag?.target_type || "");
|
||||
if (!["order_item", "order_item_field"].includes(targetType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Number(flag?.order_id || flag?.context?.order_id || 0) === Number(order?.id || 0);
|
||||
}));
|
||||
const getOrderContainedFlags = (order) => sortInvoicePeriodFlags([
|
||||
...getOrderLineFlags(order),
|
||||
...getOrderItemLineFlags(order),
|
||||
]);
|
||||
const getOrderIndicatorIconClass = (order) => {
|
||||
if (getOrderContainedFlags(order).length > 0) {
|
||||
return "fas fa-flag";
|
||||
}
|
||||
|
||||
return isPendingHandheld(order) ? "fas fa-mobile-alt" : "fas fa-circle";
|
||||
};
|
||||
const getOrderIndicatorColorClass = (order) => {
|
||||
const flags = getOrderContainedFlags(order);
|
||||
if (flags.length === 0) {
|
||||
return getOrderInvoiceStatusBarColor(order);
|
||||
}
|
||||
|
||||
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
|
||||
};
|
||||
const emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag);
|
||||
const emitFlagCreated = (flag) => emit("flagCreated", flag);
|
||||
|
||||
/**
|
||||
* Order actions modal
|
||||
*/
|
||||
@@ -764,7 +857,7 @@ const formatCashierName = (order) => {
|
||||
@click="invoiceSelectedCollections()"
|
||||
:disabled="selectedInvoiceCollections.length === 0 || isInvoiceQueueBusy"
|
||||
>
|
||||
{{ $t("global.invoice") }} {{ $t("global.selected_multiple") }} ({{
|
||||
{{ $t("common.invoice") }} {{ $t("global.selected_multiple") }} ({{
|
||||
selectedInvoiceCollections.length
|
||||
}})
|
||||
</button>
|
||||
@@ -777,8 +870,8 @@ const formatCashierName = (order) => {
|
||||
'is-dark': isInvoiceCollectionSelectedAll(),
|
||||
}"
|
||||
>
|
||||
{{ isInvoiceCollectionSelectedAll() ? $t("global.unselect") : $t("global.select") }}
|
||||
{{ $t("global.all").toLowerCase() }}
|
||||
{{ isInvoiceCollectionSelectedAll() ? $t("global.unselect") : $t("common.select") }}
|
||||
{{ $t("common.all").toLowerCase() }}
|
||||
</button>
|
||||
<!-- Expand / Collapse all invoice collections -->
|
||||
<button
|
||||
@@ -790,7 +883,7 @@ const formatCashierName = (order) => {
|
||||
}"
|
||||
>
|
||||
{{ isAutoExpandAll() ? $t("global.collapse") : $t("global.expand") }}
|
||||
{{ $t("global.all").toLowerCase() }}
|
||||
{{ $t("common.all").toLowerCase() }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -847,8 +940,11 @@ const formatCashierName = (order) => {
|
||||
isFirstOrderInCollection(order.invoice_collection_id, order)
|
||||
"
|
||||
>
|
||||
<tr>
|
||||
<td colspan="13">
|
||||
<tr :data-testid="`pos-order-invoice-collection-row-${order.invoice_collection_id}`">
|
||||
<td
|
||||
:colspan="invoiceCollectionSummaryColumnSpan"
|
||||
:data-testid="`pos-order-invoice-collection-summary-${order.invoice_collection_id}`"
|
||||
>
|
||||
<div class="has-text-weight-bold">
|
||||
Faktura samling ID: {{ order.invoice_collection_id }}
|
||||
<span class="has-text-grey"
|
||||
@@ -860,8 +956,10 @@ const formatCashierName = (order) => {
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Spacer -->
|
||||
<td colspan="2">
|
||||
<td
|
||||
class="has-text-right"
|
||||
:data-testid="`pos-order-invoice-collection-total-${order.invoice_collection_id}`"
|
||||
>
|
||||
<!-- Total net amount for the invoice collection -->
|
||||
<span class="has-text-weight-bold">
|
||||
{{
|
||||
@@ -927,7 +1025,11 @@ const formatCashierName = (order) => {
|
||||
</template>
|
||||
<template v-if="props.invoiceView">
|
||||
<td class="is-narrow">
|
||||
<button class="button is-small" @click="toggleDropdown(order)">
|
||||
<button
|
||||
class="button is-small"
|
||||
:data-testid="`pos-order-expand-${order.id}`"
|
||||
@click="toggleDropdown(order)"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
:class="isDropdownActive(order) ? 'has-text-link' : ''"
|
||||
@@ -941,8 +1043,8 @@ const formatCashierName = (order) => {
|
||||
</template>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-bind:color_class="getOrderInvoiceStatusBarColor(order)"
|
||||
v-bind:icon_class="isPendingHandheld(order) ? 'fas fa-mobile-alt' : 'fas fa-circle'"
|
||||
v-bind:color_class="getOrderIndicatorColorClass(order)"
|
||||
v-bind:icon_class="getOrderIndicatorIconClass(order)"
|
||||
v-bind:is_narrow="true"
|
||||
v-bind:label="{
|
||||
text: order.id,
|
||||
@@ -1158,9 +1260,15 @@ const formatCashierName = (order) => {
|
||||
v-bind:user_id="order.user_id"
|
||||
v-bind:order_id="order.id"
|
||||
v-bind:invoice_collection_id="order.invoice_collection_id"
|
||||
v-bind:customer_number="order.customer_id"
|
||||
v-bind:department_id="order.department_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
v-bind:reg_2="order.reg_2"
|
||||
v-bind:reg_3="order.reg_3"
|
||||
v-bind:order_booking_id="order.booking_id"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
@flag-created="emitFlagCreated"
|
||||
:data-testid="`pos-order-list-settings-${order.id}`"
|
||||
>
|
||||
<template v-slot:actions>
|
||||
@@ -1185,13 +1293,29 @@ const formatCashierName = (order) => {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="getOrderLineFlags(order).length > 0"
|
||||
:data-testid="`pos-order-flags-row-${order.id}`"
|
||||
>
|
||||
<td colspan="100%">
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderLineFlags(order)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Dropdown content (if the invoice view is enabled,and the dropdown is active) -->
|
||||
<tr v-if="props.invoiceView && isDropdownActive(order)">
|
||||
<!-- Order line items -->
|
||||
<td colspan="100%">
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<OrderContentTable :order-id="order.id" />
|
||||
<OrderContentTable
|
||||
:order-id="order.id"
|
||||
:invoice-period-flags="getOrderItemLineFlags(order)"
|
||||
@flagStatusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1347,6 +1471,11 @@ const formatCashierName = (order) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderLineFlags(order)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div>
|
||||
@@ -1377,9 +1506,15 @@ const formatCashierName = (order) => {
|
||||
v-bind:user_id="order.user_id"
|
||||
v-bind:order_id="order.id"
|
||||
v-bind:invoice_collection_id="order.invoice_collection_id"
|
||||
v-bind:customer_number="order.customer_id"
|
||||
v-bind:department_id="order.department_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
v-bind:reg_2="order.reg_2"
|
||||
v-bind:reg_3="order.reg_3"
|
||||
v-bind:order_booking_id="order.booking_id"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
@flag-created="emitFlagCreated"
|
||||
:displayActionsDirectly="true"
|
||||
>
|
||||
<template #actions></template>
|
||||
@@ -1392,7 +1527,11 @@ const formatCashierName = (order) => {
|
||||
</div>
|
||||
<!-- Order line items -->
|
||||
<div class="content" v-if="props.invoiceView && isAutoExpandAll()">
|
||||
<OrderContentTable :order-id="order.id" />
|
||||
<OrderContentTable
|
||||
:order-id="order.id"
|
||||
:invoice-period-flags="getOrderItemLineFlags(order)"
|
||||
@flagStatusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1457,7 +1596,47 @@ const formatCashierName = (order) => {
|
||||
</div>
|
||||
|
||||
<!-- Orders as cards -->
|
||||
<template v-for="order in getVisibleOrders" :key="order.id">
|
||||
<template
|
||||
v-for="order in props.groupInvoiceCollection ? getVisibleOrdersSortedByInvoiceCollection : getVisibleOrders"
|
||||
:key="order.id"
|
||||
>
|
||||
<!-- If grouping by invoice collection is enabled, show the invoice collection id -->
|
||||
<template
|
||||
v-if="
|
||||
props.groupInvoiceCollection &&
|
||||
doesOrderHaveInvoiceCollection(order) &&
|
||||
isFirstOrderInCollection(order.invoice_collection_id, order)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="column is-full"
|
||||
:data-testid="`pos-order-invoice-collection-row-${order.invoice_collection_id}`"
|
||||
>
|
||||
<div class="message is-light mb-0">
|
||||
<div
|
||||
class="message-body is-flex is-justify-content-space-between is-align-items-center p-2"
|
||||
:data-testid="`pos-order-invoice-collection-summary-${order.invoice_collection_id}`"
|
||||
>
|
||||
<div class="has-text-weight-bold">
|
||||
Faktura samling ID: {{ order.invoice_collection_id }}
|
||||
</div>
|
||||
<div class="has-text-weight-bold">
|
||||
<span :data-testid="`pos-order-invoice-collection-total-${order.invoice_collection_id}`"
|
||||
>{{
|
||||
SessionUser.functions.currency.toLocal(
|
||||
getOrdersByInvoiceCollection(order.invoice_collection_id)
|
||||
.reduce((sum, order) => sum + order.total_net_amount, 0)
|
||||
.toFixed(2)
|
||||
)
|
||||
}}
|
||||
kr.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="column is-full">
|
||||
<WhiteBoxCard
|
||||
:forceStateFooter="true"
|
||||
@@ -1468,6 +1647,7 @@ const formatCashierName = (order) => {
|
||||
:has-hover-effect="true"
|
||||
:hasSelectionStyle="false"
|
||||
:data-testid="`pos-order-list-card-${order.id}`"
|
||||
:headerTestId="`pos-order-expand-${order.id}`"
|
||||
>
|
||||
<template #header>
|
||||
<div
|
||||
@@ -1478,9 +1658,17 @@ const formatCashierName = (order) => {
|
||||
<div>
|
||||
<small>
|
||||
<!-- ID and status icon -->
|
||||
<span class="icon mr-1" :class="getOrderInvoiceStatusBarColor(order)">
|
||||
<i :class="isPendingHandheld(order) ? 'fas fa-mobile-alt' : 'fas fa-circle'"></i>
|
||||
</span>
|
||||
<ColorIndicator
|
||||
class="mr-1"
|
||||
style="display: inline-flex"
|
||||
v-bind:color_class="getOrderIndicatorColorClass(order)"
|
||||
v-bind:icon_class="getOrderIndicatorIconClass(order)"
|
||||
v-bind:is_narrow="true"
|
||||
v-bind:visibility="{
|
||||
icon: true,
|
||||
dropdown: false,
|
||||
}"
|
||||
/>
|
||||
{{ order.reg_1 }}<span v-if="order.reg_2">, {{ order.reg_2 }}</span
|
||||
><span v-if="order.reg_3">, {{ order.reg_3 }}</span>
|
||||
<span class="has-text-grey-light">#{{ order.id }}</span></small
|
||||
@@ -1596,6 +1784,8 @@ const formatCashierName = (order) => {
|
||||
:order-id="order.id"
|
||||
:summaryMode="true"
|
||||
:display-price="SessionUser.canAccessAdmin()"
|
||||
:invoice-period-flags="getOrderItemLineFlags(order)"
|
||||
@flagStatusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</div>
|
||||
<div class="column is-12 has-text-centered">
|
||||
@@ -1605,7 +1795,7 @@ const formatCashierName = (order) => {
|
||||
<br />
|
||||
<!-- Cashier • Time ago -->
|
||||
<small class="has-text-grey"
|
||||
>{{ order.cashier_name }} • {{ SessionUser.functions.date.timeAgo(order.created_at) }}</small
|
||||
>{{ order.cashier_name }} • {{ SessionUser.functions.date.toLocalTimeHours(order.created_at) }}</small
|
||||
>
|
||||
<!-- Reg 1 • Reg 2 • Reg 3 -->
|
||||
<br />
|
||||
@@ -1703,6 +1893,11 @@ const formatCashierName = (order) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderLineFlags(order)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</template>
|
||||
<template #content> </template>
|
||||
<template #footer>
|
||||
@@ -1765,6 +1960,7 @@ const formatCashierName = (order) => {
|
||||
/>
|
||||
<!-- bottom progress bar -->
|
||||
<InvoicingBillingPeriodInvoiceProgressBar
|
||||
v-if="shouldShowLegacyInvoiceProgressBar"
|
||||
v-bind:max-progress="
|
||||
invoiceCollectionQueueSuccess.length +
|
||||
invoiceCollectionQueueFailed.length +
|
||||
@@ -1806,6 +2002,7 @@ const formatCashierName = (order) => {
|
||||
v-bind:user_id="selectedOrderForActionsMenu.user_id"
|
||||
v-bind:order_id="selectedOrderForActionsMenu.id"
|
||||
v-bind:invoice_collection_id="selectedOrderForActionsMenu.invoice_collection_id"
|
||||
v-bind:customer_number="selectedOrderForActionsMenu.customer_id"
|
||||
v-bind:reg_1="selectedOrderForActionsMenu.reg_1"
|
||||
v-bind:reg_2="selectedOrderForActionsMenu.reg_2"
|
||||
v-bind:reg_3="selectedOrderForActionsMenu.reg_3"
|
||||
@@ -1813,6 +2010,7 @@ const formatCashierName = (order) => {
|
||||
v-bind:department_id="selectedOrderForActionsMenu.department_id"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
@flag-created="emitFlagCreated"
|
||||
:displayActionsDirectly="true"
|
||||
>
|
||||
<template #actions></template>
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
getCurrentStep,
|
||||
clearCache,
|
||||
nextStep,
|
||||
copyLastWashItemsToCurrentOrder,
|
||||
order_id,
|
||||
customer_id,
|
||||
step,
|
||||
setDesktopStep1PreflightHandler,
|
||||
clearDesktopStep1PreflightHandler,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
@@ -24,6 +28,7 @@ import SelectVehicleFormPOS from "@/components/forms/department/pos/SelectVehicl
|
||||
import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue";
|
||||
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
|
||||
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
|
||||
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
|
||||
import {
|
||||
@@ -58,6 +63,8 @@ const right_tabs = ref([
|
||||
|
||||
const getPreferredRailTab = () => (customer_name.value ? "customer" : "license_plates");
|
||||
const forceActiveTab = ref(getPreferredRailTab());
|
||||
const activeRailTab = ref(getPreferredRailTab());
|
||||
const customerPanelActiveTab = ref("details");
|
||||
const selectVehicleFormRef = ref(null);
|
||||
|
||||
const createEmptyDesktopStep1Context = () => ({
|
||||
@@ -83,17 +90,28 @@ const duplicateOrders = ref([]);
|
||||
const duplicateWarningKey = ref("");
|
||||
const duplicateDetailsExpanded = ref(false);
|
||||
const pendingNextResolution = ref(false);
|
||||
const isDesktopLastWashCopying = ref(false);
|
||||
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
|
||||
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
|
||||
|
||||
const setTab = (tab) => {
|
||||
forceActiveTab.value = tab;
|
||||
activeRailTab.value = tab;
|
||||
};
|
||||
|
||||
const setCustomerPanelTab = (tab) => {
|
||||
customerPanelActiveTab.value = tab || "details";
|
||||
};
|
||||
|
||||
watch(
|
||||
customer_name,
|
||||
(newValue) => {
|
||||
forceActiveTab.value = newValue ? "customer" : "license_plates";
|
||||
const nextRailTab = newValue ? "customer" : "license_plates";
|
||||
forceActiveTab.value = nextRailTab;
|
||||
activeRailTab.value = nextRailTab;
|
||||
if (!newValue) {
|
||||
customerPanelActiveTab.value = "details";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
@@ -249,10 +267,10 @@ const bookingSelectionObjects = computed(() => {
|
||||
const contentSegments = [
|
||||
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
|
||||
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
|
||||
`${t("admin.pos.order_booking_selector.reference_label")}: ${
|
||||
`${t("common.reference")}: ${
|
||||
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
|
||||
}`,
|
||||
`${t("admin.pos.order_booking_selector.services_label")}: ${
|
||||
`${t("common.services")}: ${
|
||||
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
|
||||
}`,
|
||||
];
|
||||
@@ -266,7 +284,7 @@ const bookingSelectionObjects = computed(() => {
|
||||
content: contentSegments.join(" • "),
|
||||
buttons: [
|
||||
{
|
||||
label: t("admin.pos.order_booking_selector.use_booking"),
|
||||
label: t("common.select"),
|
||||
action: () => handleBookingSelection(booking),
|
||||
color: "primary",
|
||||
testId: `pos-desktop-order-booking-use-${booking.id}`,
|
||||
@@ -307,6 +325,18 @@ const isInlineDuplicateWarningVisible = computed(() => {
|
||||
return duplicateWarningKey.value === getDuplicateStateKey(normalizedContext);
|
||||
});
|
||||
|
||||
const isCustomerRulesPanelActive = computed(
|
||||
() => activeRailTab.value === "customer" && customerPanelActiveTab.value === "rules"
|
||||
);
|
||||
const shouldShowDuplicateWarningInRulesFooter = computed(
|
||||
() => isInlineDuplicateWarningVisible.value && isCustomerRulesPanelActive.value
|
||||
);
|
||||
const shouldShowDuplicateWarningInActionRail = computed(
|
||||
() => isInlineDuplicateWarningVisible.value && !isCustomerRulesPanelActive.value
|
||||
);
|
||||
const shouldShowRulesFooterActions = computed(() => isCustomerRulesPanelActive.value);
|
||||
const shouldShowActionRailControls = computed(() => !isCustomerRulesPanelActive.value);
|
||||
|
||||
const duplicateDetailsToggleLabel = computed(() =>
|
||||
duplicateDetailsExpanded.value ? t("admin.pos.hide_order_details") : t("admin.pos.show_order_details")
|
||||
);
|
||||
@@ -604,6 +634,48 @@ const handleCustomerConflictCancel = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const pushDesktopStepTwoRoute = () => {
|
||||
if (!order_id.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
|
||||
};
|
||||
|
||||
const handleDesktopLastWashCopy = async (payload = {}) => {
|
||||
if (isDesktopLastWashCopying.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDesktopLastWashCopying.value = true;
|
||||
|
||||
try {
|
||||
await desktopStep1CoordinationPromise;
|
||||
desktopStep1CoordinationPromise = coordinateDesktopStep1({
|
||||
reason: "copy_last_wash",
|
||||
finalizeReg1Input: true,
|
||||
});
|
||||
const result = await desktopStep1CoordinationPromise;
|
||||
if (!result.canProceed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || [], {
|
||||
sourceReference: payload?.order?.reference,
|
||||
});
|
||||
if (!didCopy) {
|
||||
return;
|
||||
}
|
||||
|
||||
step.value = 2;
|
||||
pushDesktopStepTwoRoute();
|
||||
} catch (error) {
|
||||
console.error("Unable to copy last wash to current order:", error);
|
||||
} finally {
|
||||
isDesktopLastWashCopying.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
||||
});
|
||||
@@ -631,8 +703,10 @@ watch(
|
||||
<template #default>
|
||||
<SelectVehicleFormPOS
|
||||
ref="selectVehicleFormRef"
|
||||
:is-desktop-last-wash-copying="isDesktopLastWashCopying"
|
||||
@update:desktopStep1Context="setDesktopStep1Context"
|
||||
@commit:desktopStep1="handleDesktopStep1Commit"
|
||||
@copy-last-wash="handleDesktopLastWashCopy"
|
||||
/>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
@@ -649,7 +723,23 @@ watch(
|
||||
@update:activeTab="setTab"
|
||||
>
|
||||
<template #customer>
|
||||
<PosSelectedCustomer variant="sidebar" />
|
||||
<PosSelectedCustomer variant="sidebar" @update:activeTab="setCustomerPanelTab">
|
||||
<template v-if="shouldShowRulesFooterActions" #rules-footer>
|
||||
<PosDesktopDuplicateWarning
|
||||
v-if="shouldShowDuplicateWarningInRulesFooter"
|
||||
:title="t('admin.pos.warning')"
|
||||
:message="t('admin.pos.duplicate_order_warning')"
|
||||
:toggle-label="duplicateDetailsToggleLabel"
|
||||
:details-expanded="duplicateDetailsExpanded"
|
||||
:duplicate-orders="duplicateDetailsObjects"
|
||||
@toggle-details="toggleDuplicateDetails"
|
||||
/>
|
||||
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
|
||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||
<NextStep class="is-fullwidth" tabindex="6" />
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</PosSelectedCustomer>
|
||||
</template>
|
||||
<template #license_plates>
|
||||
<PosLastScannedLicensePlatesV2 />
|
||||
@@ -662,62 +752,20 @@ watch(
|
||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||
<Cancel tabindex="2" class="is-fullwidth" />
|
||||
</ButtonsBox>
|
||||
<div class="pos-shell-actions__rail">
|
||||
<div
|
||||
v-if="isInlineDuplicateWarningVisible"
|
||||
class="notification is-warning is-light pos-duplicate-warning"
|
||||
data-testid="pos-desktop-duplicate-warning-inline"
|
||||
>
|
||||
<div class="pos-duplicate-warning__header">
|
||||
<div class="pos-duplicate-warning__header-copy">
|
||||
<span class="icon pos-duplicate-warning__icon" aria-hidden="true">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
</span>
|
||||
<p class="pos-duplicate-warning__title">{{ t("admin.pos.warning") }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-warning is-light"
|
||||
data-testid="pos-desktop-duplicate-warning-toggle-details"
|
||||
@click="toggleDuplicateDetails"
|
||||
>
|
||||
{{ duplicateDetailsToggleLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="pos-duplicate-warning__message">
|
||||
{{ t("admin.pos.duplicate_order_warning") }}
|
||||
</p>
|
||||
<div
|
||||
v-if="duplicateDetailsExpanded"
|
||||
class="pos-duplicate-warning__details"
|
||||
data-testid="pos-desktop-duplicate-warning-details-inline"
|
||||
>
|
||||
<div
|
||||
v-for="duplicateOrder in duplicateDetailsObjects"
|
||||
:key="duplicateOrder.id"
|
||||
class="pos-duplicate-warning__detail-card"
|
||||
:data-testid="`pos-desktop-duplicate-warning-detail-${duplicateOrder.id}`"
|
||||
>
|
||||
<div class="pos-duplicate-warning__detail-copy">
|
||||
<p class="pos-duplicate-warning__detail-title">{{ duplicateOrder.label }}</p>
|
||||
<p class="pos-duplicate-warning__detail-content">{{ duplicateOrder.content }}</p>
|
||||
</div>
|
||||
<div class="buttons pos-duplicate-warning__detail-actions">
|
||||
<button
|
||||
v-for="button in duplicateOrder.buttons"
|
||||
:key="button.label"
|
||||
type="button"
|
||||
class="button is-small"
|
||||
:class="`is-${button.color || 'dark'}`"
|
||||
:data-testid="button.testId"
|
||||
@click="button.action()"
|
||||
>
|
||||
{{ button.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldShowActionRailControls"
|
||||
class="pos-shell-actions__rail"
|
||||
data-testid="pos-step-1-action-rail"
|
||||
>
|
||||
<PosDesktopDuplicateWarning
|
||||
v-if="shouldShowDuplicateWarningInActionRail"
|
||||
:title="t('admin.pos.warning')"
|
||||
:message="t('admin.pos.duplicate_order_warning')"
|
||||
:toggle-label="duplicateDetailsToggleLabel"
|
||||
:details-expanded="duplicateDetailsExpanded"
|
||||
:duplicate-orders="duplicateDetailsObjects"
|
||||
@toggle-details="toggleDuplicateDetails"
|
||||
/>
|
||||
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
|
||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||
<NextStep class="is-fullwidth" tabindex="6" />
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ const closeModal = () => {
|
||||
data-testid="pos-desktop-customer-conflict-cancel"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ t("admin.pos.customer_conflict.cancel") }}
|
||||
{{ t("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary is-light"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
toggleLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
detailsExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
duplicateOrders: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["toggle-details"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="notification is-warning is-light pos-duplicate-warning"
|
||||
data-testid="pos-desktop-duplicate-warning-inline"
|
||||
>
|
||||
<div class="pos-duplicate-warning__header">
|
||||
<div class="pos-duplicate-warning__header-copy">
|
||||
<span class="icon pos-duplicate-warning__icon" aria-hidden="true">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
</span>
|
||||
<p class="pos-duplicate-warning__title">{{ title }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-warning is-light"
|
||||
data-testid="pos-desktop-duplicate-warning-toggle-details"
|
||||
@click="emit('toggle-details')"
|
||||
>
|
||||
{{ toggleLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="pos-duplicate-warning__message">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div
|
||||
v-if="detailsExpanded"
|
||||
class="pos-duplicate-warning__details"
|
||||
data-testid="pos-desktop-duplicate-warning-details-inline"
|
||||
>
|
||||
<div
|
||||
v-for="duplicateOrder in duplicateOrders"
|
||||
:key="duplicateOrder.id"
|
||||
class="pos-duplicate-warning__detail-card"
|
||||
:data-testid="`pos-desktop-duplicate-warning-detail-${duplicateOrder.id}`"
|
||||
>
|
||||
<div class="pos-duplicate-warning__detail-copy">
|
||||
<p class="pos-duplicate-warning__detail-title">{{ duplicateOrder.label }}</p>
|
||||
<p class="pos-duplicate-warning__detail-content">{{ duplicateOrder.content }}</p>
|
||||
</div>
|
||||
<div class="buttons pos-duplicate-warning__detail-actions">
|
||||
<button
|
||||
v-for="button in duplicateOrder.buttons"
|
||||
:key="button.label"
|
||||
type="button"
|
||||
class="button is-small"
|
||||
:class="`is-${button.color || 'dark'}`"
|
||||
:data-testid="button.testId"
|
||||
@click="button.action()"
|
||||
>
|
||||
{{ button.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,8 +8,13 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
copying: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["copy"]);
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const order = ref(null);
|
||||
@@ -78,12 +83,52 @@ const groupedOrderItems = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const getOrderItemProductId = (item) => parsePositiveInteger(item?.product?.id ?? item?.product_id);
|
||||
const getOrderItemQuantity = (item) => {
|
||||
const parsedQuantity = Number.parseInt(String(item?.quantity ?? 1), 10);
|
||||
return Number.isInteger(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : null;
|
||||
};
|
||||
|
||||
const hasUsableOrderItems = computed(() => {
|
||||
return groupedOrderItems.value.some((group) => getOrderItemProductId(group.item) && getOrderItemQuantity(group.item));
|
||||
});
|
||||
|
||||
const isCopyDisabled = computed(() => loadState.value !== "loaded" || props.copying || !hasUsableOrderItems.value);
|
||||
const copyButtonLabel = computed(() => {
|
||||
const lastWashLabel = SessionUser.objects.global.language.last_wash || t("common.last_wash");
|
||||
return `${SessionUser.objects.global.language.copy || t("common.copy")} ${lastWashLabel.toLowerCase()}`;
|
||||
});
|
||||
const copyButtonText = computed(() => (props.copying ? t("pos.copying_last_wash") : copyButtonLabel.value));
|
||||
const copyButtonState = computed(() => {
|
||||
if (props.copying) {
|
||||
return "copying";
|
||||
}
|
||||
|
||||
if (isCopyDisabled.value) {
|
||||
return "disabled";
|
||||
}
|
||||
|
||||
return "ready";
|
||||
});
|
||||
|
||||
const onCopy = () => {
|
||||
if (isCopyDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("copy", {
|
||||
order: order.value,
|
||||
orderId: normalizedOrderId.value,
|
||||
items: orderItems.value,
|
||||
});
|
||||
};
|
||||
|
||||
const lastWashMetadata = computed(() => {
|
||||
const currentOrder = order.value || {};
|
||||
return [
|
||||
{
|
||||
key: "created",
|
||||
label: t("pos.order.created"),
|
||||
label: t("common.created"),
|
||||
value: formatDateTime(currentOrder.created_at),
|
||||
},
|
||||
{
|
||||
@@ -145,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>
|
||||
@@ -181,12 +226,15 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
|
||||
type="button"
|
||||
class="button is-small is-light pos-step-one-copy-action"
|
||||
data-testid="pos-desktop-last-wash-copy"
|
||||
disabled
|
||||
:data-state="copyButtonState"
|
||||
:disabled="isCopyDisabled"
|
||||
:aria-busy="props.copying ? 'true' : 'false'"
|
||||
@click="onCopy"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||
<i :class="props.copying ? 'fas fa-spinner fa-spin' : 'fas fa-copy'" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ `${SessionUser.objects.global.language.copy} ${SessionUser.objects.global.language.last_wash.toLowerCase()}` }}</span>
|
||||
<span>{{ copyButtonText }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
+7
-6
@@ -394,7 +394,7 @@ const closeModal = () => {
|
||||
class="tag pos-desktop-order-booking-card__today-tag"
|
||||
:data-testid="`pos-desktop-order-booking-today-${booking.id}`"
|
||||
>
|
||||
{{ t("global.time.today") }}
|
||||
{{ t("common.today") }}
|
||||
</span>
|
||||
<span v-if="didBookingDetailFail(booking)" class="tag is-light pos-desktop-order-booking-card__tag">
|
||||
{{ t("admin.pos.not_found") }}
|
||||
@@ -413,6 +413,7 @@ const closeModal = () => {
|
||||
:refreshFunction="emitRefreshBookings"
|
||||
:icon="'fas fa-ellipsis-v'"
|
||||
trigger-button-variant="text"
|
||||
allow-booking-completion
|
||||
>
|
||||
<template #actions />
|
||||
</ActionSettingsWheelButton>
|
||||
@@ -430,7 +431,7 @@ const closeModal = () => {
|
||||
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
|
||||
</div>
|
||||
<div class="pos-desktop-order-booking-card__meta-item">
|
||||
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
|
||||
<dt>{{ t("common.reference") }}</dt>
|
||||
<dd>{{ getBookingReference(booking) || "—" }}</dd>
|
||||
</div>
|
||||
<div v-if="getBookingPo(booking)" class="pos-desktop-order-booking-card__meta-item">
|
||||
@@ -454,7 +455,7 @@ const closeModal = () => {
|
||||
>
|
||||
<div class="pos-desktop-order-booking-card__section-header">
|
||||
<p class="pos-desktop-order-booking-card__section-title">
|
||||
{{ t("admin.pos.order_booking_selector.services_label") }}
|
||||
{{ t("common.services") }}
|
||||
</p>
|
||||
<div
|
||||
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
|
||||
@@ -475,8 +476,8 @@ const closeModal = () => {
|
||||
class="pos-desktop-order-booking-card__item-preview"
|
||||
>
|
||||
<div class="pos-desktop-order-booking-card__item-preview-head">
|
||||
<span>{{ t("global.quantity") }}</span>
|
||||
<span>{{ t("admin.pos.order_booking_selector.services_label") }}</span>
|
||||
<span>{{ t("common.quantity") }}</span>
|
||||
<span>{{ t("common.services") }}</span>
|
||||
<span>{{ t("admin.pos.price_dkk") }}</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -528,7 +529,7 @@ const closeModal = () => {
|
||||
:data-testid="`pos-desktop-order-booking-use-${booking.id}`"
|
||||
@click="onSelect(booking)"
|
||||
>
|
||||
{{ t("admin.pos.order_booking_selector.use_booking") }}
|
||||
{{ t("common.select") }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
+57
-5
@@ -2,12 +2,21 @@
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
getOrderBookingItemPreviewRows,
|
||||
getOrderBookingReferenceValue,
|
||||
getOrderBookingServiceLabels,
|
||||
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
|
||||
|
||||
const props = defineProps({
|
||||
vehicle: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
booking: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -19,6 +28,8 @@ const parsePositiveInteger = (value) => {
|
||||
|
||||
const normalizeStatus = (value) => String(value ?? "").trim().toLowerCase();
|
||||
|
||||
const selectedBooking = computed(() => (props.booking?.id ? props.booking : null));
|
||||
|
||||
const getVehicleStatusLabel = (vehicle) => {
|
||||
if (!vehicle) {
|
||||
return SessionUser.objects.global.language.no_data;
|
||||
@@ -78,6 +89,11 @@ const getVehicleStatusTone = (vehicle) => {
|
||||
};
|
||||
|
||||
const vehicleTypeName = computed(() => {
|
||||
const bookingPrimaryService = bookingSummaryRows.value.find((row) => !row.isAddon)?.label || bookingSummaryRows.value[0]?.label;
|
||||
if (bookingPrimaryService) {
|
||||
return bookingPrimaryService;
|
||||
}
|
||||
|
||||
const vehicleTypeId = parsePositiveInteger(props.vehicle?.type);
|
||||
if (!vehicleTypeId) {
|
||||
return SessionUser.objects.global.language.no_data;
|
||||
@@ -105,17 +121,24 @@ const vehicleMetadata = computed(() => [
|
||||
{
|
||||
key: "reg",
|
||||
label: SessionUser.objects.orders.columns.reg_1.label,
|
||||
value: String(props.vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data,
|
||||
value:
|
||||
String(selectedBooking.value?.reg_1 ?? props.vehicle?.reg ?? "").trim() ||
|
||||
SessionUser.objects.global.language.no_data,
|
||||
},
|
||||
{
|
||||
key: "customer",
|
||||
label: t("admin.pos.customer"),
|
||||
value: String(props.vehicle?.customer_name ?? "").trim() || SessionUser.objects.global.language.no_data,
|
||||
value:
|
||||
String(selectedBooking.value?.customer_name ?? props.vehicle?.customer_name ?? "").trim() ||
|
||||
SessionUser.objects.global.language.no_data,
|
||||
},
|
||||
{
|
||||
key: "reference",
|
||||
label: SessionUser.objects.orders.columns.reference.label,
|
||||
value: String(props.vehicle?.reference ?? "").trim() || SessionUser.objects.global.language.no_data,
|
||||
value:
|
||||
getOrderBookingReferenceValue(selectedBooking.value) ||
|
||||
String(props.vehicle?.reference ?? "").trim() ||
|
||||
SessionUser.objects.global.language.no_data,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
@@ -124,7 +147,36 @@ const vehicleMetadata = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const bookingSummaryRows = computed(() => {
|
||||
if (!selectedBooking.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const previewRows = getOrderBookingItemPreviewRows(selectedBooking.value);
|
||||
if (previewRows.length > 0) {
|
||||
return previewRows.map((row) => ({
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
quantity: row.quantity,
|
||||
isAddon: row.depth > 0,
|
||||
includeInInvoice: true,
|
||||
}));
|
||||
}
|
||||
|
||||
return getOrderBookingServiceLabels(selectedBooking.value).map((label, index) => ({
|
||||
key: `booking-service-${index}`,
|
||||
label,
|
||||
quantity: 1,
|
||||
isAddon: index > 0,
|
||||
includeInInvoice: true,
|
||||
}));
|
||||
});
|
||||
|
||||
const vehicleSummaryRows = computed(() => {
|
||||
if (bookingSummaryRows.value.length > 0) {
|
||||
return bookingSummaryRows.value;
|
||||
}
|
||||
|
||||
if (!props.vehicle) {
|
||||
return [];
|
||||
}
|
||||
@@ -166,7 +218,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
|
||||
<p class="pos-step-one-insight-card__eyebrow">{{ SessionUser.objects.vehicles.meta.labels.single }}</p>
|
||||
<h3 class="pos-step-one-insight-card__title">{{ vehicleTypeName }}</h3>
|
||||
<p class="pos-step-one-insight-card__subtitle">
|
||||
{{ String(vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data }}
|
||||
{{ String(selectedBooking?.reg_1 ?? vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pos-step-one-insight-card__pills">
|
||||
@@ -196,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>
|
||||
|
||||
+9
-2
@@ -320,16 +320,23 @@ const noop = () => {};
|
||||
|
||||
.vehicle-card__media {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
border: 1px solid rgba(31, 91, 183, 0.12);
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
flex: 0 0 5.25rem;
|
||||
height: 5.25rem;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
width: 5.25rem;
|
||||
}
|
||||
|
||||
.vehicle-card__image {
|
||||
height: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.vehicle-card__details {
|
||||
|
||||
+61
-6
@@ -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 -->
|
||||
@@ -326,7 +381,7 @@ const onClickAttachWashCertificate = () => {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="card-footer-item">
|
||||
{{ t('admin.pos.created') }}: {{ new Date(attachment.created_at).toLocaleString() }}
|
||||
{{ t('common.created') }}: {{ new Date(attachment.created_at).toLocaleString() }}
|
||||
</div>
|
||||
<a class="card-footer-item" v-if="!attachment.deleted_at" @click="onClickDelete(attachment.id)">
|
||||
<!-- Delete button or action can be placed here -->
|
||||
|
||||
+12
-13
@@ -179,24 +179,23 @@ const resetAll = () => {
|
||||
reset_all_values();
|
||||
};
|
||||
|
||||
const onClickClearAllButton = () => {
|
||||
const onClickClearAllButton = async () => {
|
||||
// Check if the session storage contains the pos_order_id
|
||||
if (localStorage.getItem("pos_order_id")) {
|
||||
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
|
||||
}
|
||||
// 1. Delete the order (If any)
|
||||
if (order_id.value) {
|
||||
SessionUser.objects.orders.delete
|
||||
.single(order_id.value)
|
||||
.then(() => {
|
||||
console.warn("Order deleted successfully");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting order:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
resetAll();
|
||||
});
|
||||
try {
|
||||
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
console.warn("Order deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Error deleting order:", error);
|
||||
}
|
||||
resetAll();
|
||||
} else {
|
||||
resetAll();
|
||||
}
|
||||
@@ -294,7 +293,7 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
{{ t("admin.pos.reference") }}
|
||||
{{ t("common.reference") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+27
-35
@@ -1,46 +1,38 @@
|
||||
<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 onClickClearAll = () => {
|
||||
// 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) {
|
||||
SessionUser.objects.orders.delete
|
||||
.single(order_id.value)
|
||||
.then(() => {
|
||||
// 2. Reset the POS
|
||||
resetPos();
|
||||
// 3. Reset all values
|
||||
reset_all_values();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting order:", error);
|
||||
resetPos();
|
||||
reset_all_values();
|
||||
});
|
||||
const toPositiveInteger = (value: unknown) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const onClickClearAll = async () => {
|
||||
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 {
|
||||
order_id.value = activeDraftOrderId;
|
||||
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(activeDraftOrderId);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting order:", error);
|
||||
}
|
||||
}
|
||||
// 2. Reset the POS
|
||||
resetPos();
|
||||
// 3. Reset all values
|
||||
reset_all_values();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -54,7 +46,7 @@ const onClickClearAll = () => {
|
||||
>
|
||||
<div class="columns is-mobile is-vcentered has-text-centered">
|
||||
<!-- Clear all -->
|
||||
<div class="column is-12 has-text-centered" @click="onClickClearAll()">
|
||||
<div class="column is-12 has-text-centered">
|
||||
<div class="columns is-mobile is-vcentered is-centered">
|
||||
<div class="column is-narrow pr-1">
|
||||
<span class="icon is-small">
|
||||
|
||||
+46
-26
@@ -132,7 +132,7 @@ const promptForReference = async (initialValue = "") => {
|
||||
title: t("admin.pos.reference_required_title"),
|
||||
text: t("admin.pos.reference_required_text"),
|
||||
input: "text",
|
||||
inputLabel: t("admin.pos.reference"),
|
||||
inputLabel: t("common.reference"),
|
||||
inputValue: initialValue,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t("admin.pos.confirm"),
|
||||
@@ -177,7 +177,6 @@ const validateReferenceRequirements = async () => {
|
||||
reference.value = enteredRef;
|
||||
metadata.setReference(enteredRef);
|
||||
console.warn("New reference set:", enteredRef);
|
||||
onClick(); // Retry the click action
|
||||
return true;
|
||||
}
|
||||
console.warn("Reference number is required but was not provided. Aborting operation.");
|
||||
@@ -188,7 +187,7 @@ const validateReferenceRequirements = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const step1 = () => {
|
||||
const step1 = async () => {
|
||||
/** This function can be used to perform any specific actions for step 1 */
|
||||
// Set registration numbers
|
||||
reg_1.value = vehicles.vehicle_1.value?.reg || "";
|
||||
@@ -202,18 +201,14 @@ const step1 = () => {
|
||||
mergeSafetySeal();
|
||||
// We only need to check if the customer requires reference, if the field is empty.
|
||||
if (!isNonEmptyString(reference.value)) {
|
||||
validateReferenceRequirements().then((isValid) => {
|
||||
if (!isValid) {
|
||||
// The requirements were not met, do not proceed.
|
||||
return;
|
||||
}
|
||||
// Proceed with setting reference and notes
|
||||
finalizeStep1();
|
||||
});
|
||||
} else {
|
||||
// Reference is already filled, just finalize step 1
|
||||
finalizeStep1();
|
||||
const isValid = await validateReferenceRequirements();
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reference is resolved, persist metadata and pending attachments.
|
||||
return finalizeStep1();
|
||||
};
|
||||
const mergeReferences = () => {
|
||||
if (metadata.getReference() && metadata.getReference().length >= 1) {
|
||||
@@ -246,25 +241,34 @@ const mergeSafetySeal = () => {
|
||||
syncMobileSafetySealState(resolvedSafetySeal);
|
||||
updateSafetySeal();
|
||||
};
|
||||
const finalizeStep1 = () => {
|
||||
const finalizeStep1 = async () => {
|
||||
mergeReferences();
|
||||
mergeNotes();
|
||||
mergeSafetySeal();
|
||||
// Upload the attachments if there are any (And the order id has been created)
|
||||
if (attachments.getBase64().length > 0 && order_id.value > 0) {
|
||||
for (const attachment of attachments.getBase64()) {
|
||||
uploadAttachment(attachment)
|
||||
.then(() => {
|
||||
console.warn("Attachment uploaded successfully");
|
||||
// Remove the attachment from the list
|
||||
attachments.removeBase64(attachment);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
const uploadResults = await Promise.all(
|
||||
attachments.getBase64().map(async (attachment: any) => {
|
||||
try {
|
||||
const didUpload = await uploadAttachment(attachment);
|
||||
if (didUpload) {
|
||||
console.warn("Attachment uploaded successfully");
|
||||
attachments.removeBase64(attachment);
|
||||
}
|
||||
return didUpload;
|
||||
} catch (error: any) {
|
||||
errors.value.push(error);
|
||||
console.warn("An error occurred while uploading the attachment:", error);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (uploadResults.some((didUpload) => didUpload === false)) {
|
||||
console.warn("One or more attachments could not be uploaded before completion.");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const addTransactionToHistory = async (orderId: number) => {
|
||||
@@ -320,10 +324,21 @@ const step2 = async () => {
|
||||
|
||||
const selectedBookingId = getSelectedBookingId();
|
||||
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
|
||||
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
||||
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
|
||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
||||
|
||||
try {
|
||||
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
|
||||
syncMobileSafetySealState(resolvedSafetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: resolvedSafetySeal,
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresBookingCompletionPopup) {
|
||||
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
|
||||
syncMobileSafetySealState(safetySeal);
|
||||
@@ -401,12 +416,15 @@ const onClick = async () => {
|
||||
// Proceed to the next step
|
||||
switch (step.value) {
|
||||
case 1:
|
||||
step1();
|
||||
if (!(await step1())) {
|
||||
return;
|
||||
}
|
||||
const restoredOrderId = await restoreStoredPosOrderId({
|
||||
validateOrder: true,
|
||||
customerId: getCustomerId(),
|
||||
departmentId: POSDepartmentProcess.getDepartment(),
|
||||
allowCompleted: false,
|
||||
syncDepartment: true,
|
||||
});
|
||||
if (restoredOrderId) {
|
||||
console.warn("Order ID retrieved from local storage:", restoredOrderId);
|
||||
@@ -424,7 +442,9 @@ const onClick = async () => {
|
||||
});
|
||||
break;
|
||||
case 2:
|
||||
step1();
|
||||
if (!(await step1())) {
|
||||
return;
|
||||
}
|
||||
await step2();
|
||||
break;
|
||||
case 3:
|
||||
|
||||
+7
-1
@@ -9,6 +9,9 @@ const product = props.product;
|
||||
const note = ref(product.notes || "");
|
||||
watch(note, (newNote) => {
|
||||
product.notes = newNote;
|
||||
if (props) {
|
||||
props.validationMessage = "";
|
||||
}
|
||||
// If the note is empty, remove it from the product
|
||||
if (newNote === "") {
|
||||
delete product.notes;
|
||||
@@ -28,6 +31,9 @@ watch(note, (newNote) => {
|
||||
placeholder="Indtast note"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="props?.validationMessage" class="help is-danger mt-2">
|
||||
{{ props.validationMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -125,4 +131,4 @@ input.is-searched {
|
||||
flex-grow: 0;
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+35
-33
@@ -4,11 +4,15 @@ import { metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { getResolvedMobileSafetySeal } from "../objects/mobileOrderCompletion.js";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const popupProps = computed(() => popups.get()?.props || {});
|
||||
const isSubmitting = ref(false);
|
||||
type SubmitAction = "without_certificate" | "with_certificate";
|
||||
|
||||
const submittingAction = ref<SubmitAction | null>(null);
|
||||
const isSubmitting = computed(() => submittingAction.value !== null);
|
||||
const isSubmittingAction = (action: SubmitAction) => submittingAction.value === action;
|
||||
const resolveExistingSafetySeal = () => {
|
||||
const popupSafetySeal = popupProps.value?.safetySeal;
|
||||
if (popupSafetySeal !== null && popupSafetySeal !== undefined) {
|
||||
@@ -20,11 +24,17 @@ const resolveExistingSafetySeal = () => {
|
||||
const safetySealNumber = ref(resolveExistingSafetySeal());
|
||||
|
||||
const closePopup = () => {
|
||||
emit('close');
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const runPopupCompletionCallback = async (callback: any, safetySeal: string) => {
|
||||
isSubmitting.value = true;
|
||||
const closePopupIfStillActive = () => {
|
||||
if (popups.get()?.id === "complete_booking") {
|
||||
closePopup();
|
||||
}
|
||||
};
|
||||
|
||||
const runPopupCompletionCallback = async (callback: any, safetySeal: string, action: SubmitAction) => {
|
||||
submittingAction.value = action;
|
||||
try {
|
||||
await Promise.resolve(
|
||||
callback({
|
||||
@@ -32,26 +42,26 @@ const runPopupCompletionCallback = async (callback: any, safetySeal: string) =>
|
||||
safetySeal,
|
||||
})
|
||||
);
|
||||
closePopup();
|
||||
closePopupIfStillActive();
|
||||
} catch (error) {
|
||||
console.warn("Failed to complete booking from popup callback:", error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
submittingAction.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const submitBookingCompletionFallback = async (safetySeal: string) => {
|
||||
isSubmitting.value = true;
|
||||
const submitBookingCompletionFallback = async (safetySeal: string, action: SubmitAction) => {
|
||||
submittingAction.value = action;
|
||||
try {
|
||||
await SessionUser.objects.order_bookings.functions.complete(
|
||||
metadata.getBookingId(),
|
||||
safetySeal === "" ? null : safetySeal
|
||||
);
|
||||
closePopup();
|
||||
closePopupIfStillActive();
|
||||
} catch (error) {
|
||||
console.warn("Failed to complete booking from popup fallback action:", error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
submittingAction.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,10 +72,10 @@ const completeWithoutCertificate = async () => {
|
||||
|
||||
const callback = popupProps.value?.onCompleteWithoutCertificate || popupProps.value?.onComplete;
|
||||
if (typeof callback === "function") {
|
||||
await runPopupCompletionCallback(callback, "");
|
||||
await runPopupCompletionCallback(callback, "", "without_certificate");
|
||||
return;
|
||||
}
|
||||
await submitBookingCompletionFallback("");
|
||||
await submitBookingCompletionFallback("", "without_certificate");
|
||||
};
|
||||
|
||||
const completeWithCertificate = async () => {
|
||||
@@ -76,10 +86,10 @@ const completeWithCertificate = async () => {
|
||||
const normalizedSafetySeal = String(safetySealNumber.value ?? "").trim() || resolveExistingSafetySeal();
|
||||
const callback = popupProps.value?.onCompleteWithCertificate || popupProps.value?.onComplete;
|
||||
if (typeof callback === "function") {
|
||||
await runPopupCompletionCallback(callback, normalizedSafetySeal);
|
||||
await runPopupCompletionCallback(callback, normalizedSafetySeal, "with_certificate");
|
||||
return;
|
||||
}
|
||||
await submitBookingCompletionFallback(normalizedSafetySeal);
|
||||
await submitBookingCompletionFallback(normalizedSafetySeal, "with_certificate");
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -104,29 +114,22 @@ const completeWithCertificate = async () => {
|
||||
<button
|
||||
class="button is-light"
|
||||
data-testid="pos-mobile-booking-complete-without-certificate"
|
||||
:class="{ 'is-loading': isSubmitting }"
|
||||
:class="{ 'is-loading': isSubmittingAction('without_certificate') }"
|
||||
:disabled="isSubmitting"
|
||||
@click="completeWithoutCertificate"
|
||||
>
|
||||
Intet vaskecertifikat
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary"
|
||||
data-testid="pos-mobile-booking-complete-with-certificate"
|
||||
:class="{ 'is-loading': isSubmitting }"
|
||||
:disabled="isSubmitting"
|
||||
@click="completeWithCertificate"
|
||||
class="button is-primary"
|
||||
data-testid="pos-mobile-booking-complete-with-certificate"
|
||||
:class="{ 'is-loading': isSubmittingAction('with_certificate') }"
|
||||
:disabled="isSubmitting"
|
||||
@click="completeWithCertificate"
|
||||
>
|
||||
Generer vaskecertifikat
|
||||
</button>
|
||||
</div>
|
||||
<!--<FormDisplay
|
||||
:form_identifier="'GENERATE_BOOKING_WASH_CERTIFICATE'"
|
||||
v-bind:validator_options="{validateBookingId: {default: metadata.getBookingId(), locked: true, visible: false}}"
|
||||
:onSuccessfulSubmission="onSuccessfulCertificateSubmission"
|
||||
:showSuccessSubmitAlert="false"
|
||||
:showResetButton="false"
|
||||
/>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -152,7 +155,7 @@ input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
|
||||
background: #FFFFFF;
|
||||
background: #ffffff;
|
||||
border-color: transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -185,8 +188,8 @@ input:focus-visible {
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #A6A5A5;
|
||||
background: #ffffff;
|
||||
border: 1px solid #a6a5a5;
|
||||
border-radius: 4px;
|
||||
|
||||
/* Inside auto layout */
|
||||
@@ -211,7 +214,7 @@ input.is-searched {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
|
||||
background: #FFFFFF;
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
border-radius: 4px;
|
||||
border-bottom: none;
|
||||
@@ -221,6 +224,5 @@ input.is-searched {
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
+7
-6
@@ -339,7 +339,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
||||
class="tag booking-option__today-tag"
|
||||
:data-testid="`pos-mobile-order-booking-today-${booking.id}`"
|
||||
>
|
||||
{{ t("global.time.today") }}
|
||||
{{ t("common.today") }}
|
||||
</span>
|
||||
<span v-if="didBookingDetailFail(booking)" class="tag is-light booking-option__tag">
|
||||
{{ t("admin.pos.not_found") }}
|
||||
@@ -355,6 +355,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
||||
:refreshFunction="emitRefreshBookings"
|
||||
:icon="'fas fa-ellipsis-v'"
|
||||
trigger-button-variant="text"
|
||||
allow-booking-completion
|
||||
>
|
||||
<template #actions />
|
||||
</ActionSettingsWheelButton>
|
||||
@@ -372,7 +373,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
||||
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
|
||||
</div>
|
||||
<div class="booking-option__meta-item">
|
||||
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
|
||||
<dt>{{ t("common.reference") }}</dt>
|
||||
<dd>{{ getBookingReference(booking) || "-" }}</dd>
|
||||
</div>
|
||||
<div v-if="getBookingPo(booking)" class="booking-option__meta-item">
|
||||
@@ -391,7 +392,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
||||
:data-testid="`pos-mobile-order-booking-services-${booking.id}`"
|
||||
>
|
||||
<div class="booking-option__section-header">
|
||||
<p class="booking-option__section-title">{{ t("admin.pos.order_booking_selector.services_label") }}</p>
|
||||
<p class="booking-option__section-title">{{ t("common.services") }}</p>
|
||||
<div
|
||||
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
|
||||
class="booking-option__section-total"
|
||||
@@ -408,8 +409,8 @@ const isBookingScheduledForToday = (booking: any) => {
|
||||
</div>
|
||||
<div v-else-if="getBookingItemPreviewRows(booking).length > 0" class="booking-option__item-preview">
|
||||
<div class="booking-option__item-preview-head">
|
||||
<span>{{ t("global.quantity") }}</span>
|
||||
<span>{{ t("admin.pos.order_booking_selector.services_label") }}</span>
|
||||
<span>{{ t("common.quantity") }}</span>
|
||||
<span>{{ t("common.services") }}</span>
|
||||
<span>{{ t("admin.pos.price_dkk") }}</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -455,7 +456,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
||||
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
|
||||
@click="onSelect(booking)"
|
||||
>
|
||||
{{ t("admin.pos.order_booking_selector.use_booking") }}
|
||||
{{ t("common.select") }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -4,8 +4,10 @@ export type PosDepartment = {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
order_priority?: number | null;
|
||||
priority_order?: number | null;
|
||||
branding?: number;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
</script>
|
||||
};
|
||||
</script>
|
||||
|
||||
+12
-4
@@ -133,7 +133,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
||||
return {
|
||||
/** General purpose action buttons (used by PosAddons)*/
|
||||
confirm: {
|
||||
label: t("admin.pos.action_buttons.confirm"),
|
||||
label: t("common.confirm"),
|
||||
description: t("admin.pos.action_buttons.confirm_desc"),
|
||||
onClick: () => {
|
||||
clearPopup(); // Close the popup
|
||||
@@ -141,7 +141,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
||||
color: "primary",
|
||||
},
|
||||
cancel: {
|
||||
label: t("admin.pos.action_buttons.cancel"),
|
||||
label: t("common.cancel"),
|
||||
description: t("admin.pos.action_buttons.cancel_desc"),
|
||||
onClick: () => {
|
||||
clearPopup(); // Close the popup
|
||||
@@ -149,7 +149,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
||||
color: "light",
|
||||
},
|
||||
close: {
|
||||
label: t("admin.pos.action_buttons.close"),
|
||||
label: t("common.close"),
|
||||
description: t("admin.pos.action_buttons.close_desc"),
|
||||
onClick: () => {
|
||||
clearPopup(); // Close the popup
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
order_notes,
|
||||
order_safety_seal,
|
||||
reference,
|
||||
clearStoredPosOrderId,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { metadata } from "./PosDepartmentStepMobileFlow.vue";
|
||||
|
||||
@@ -115,5 +116,6 @@ export const finalizeCurrentMobileOrder = async (options = {}) => {
|
||||
if (normalizedOptions.markOrderCompleted) {
|
||||
await SessionUser.objects.orders.functions.mark_as_completed(normalizedOrderId);
|
||||
}
|
||||
clearStoredPosOrderId();
|
||||
return normalizedOrderId;
|
||||
};
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ const handleMouseWheel = (e: WheelEvent) => {
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-times"></i>
|
||||
</span>
|
||||
<small>{{ t("global.close") }}</small>
|
||||
<small>{{ t("common.close") }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -286,7 +286,7 @@ const tableHeaders = ref([
|
||||
},
|
||||
{
|
||||
name: 'reference',
|
||||
title: t('objects.columns.reference'),
|
||||
title: t('common.reference'),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
@@ -296,7 +296,7 @@ const tableHeaders = ref([
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
title: t('objects.columns.date'),
|
||||
title: t('common.date'),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const CACHE_PREFIX = "xlvask-usage-amount:";
|
||||
const memoryCache = new Map();
|
||||
|
||||
const safeSessionStorage = () => {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.sessionStorage) {
|
||||
return null;
|
||||
}
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeUsageLogId = (objectOrId) => {
|
||||
const value = typeof objectOrId === "object"
|
||||
? objectOrId?.usage_log_id ?? objectOrId?.id
|
||||
: objectOrId;
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
};
|
||||
|
||||
const isFreshEntry = (entry) => {
|
||||
const storedAt = Number(entry?.storedAt ?? 0);
|
||||
return storedAt > 0 && Date.now() - storedAt <= XLVASK_USAGE_AMOUNT_CACHE_TTL_MS;
|
||||
};
|
||||
|
||||
export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
|
||||
const usageLogId = normalizeUsageLogId(objectOrId);
|
||||
return usageLogId > 0 ? String(usageLogId) : "";
|
||||
};
|
||||
|
||||
export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
if (!cacheKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryEntry = memoryCache.get(cacheKey);
|
||||
if (isFreshEntry(memoryEntry)) {
|
||||
return memoryEntry.data;
|
||||
}
|
||||
|
||||
if (memoryEntry) {
|
||||
memoryCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||
if (!isFreshEntry(parsed)) {
|
||||
storage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
memoryCache.set(cacheKey, parsed);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
storage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
if (!cacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
storedAt: Date.now(),
|
||||
data,
|
||||
};
|
||||
memoryCache.set(cacheKey, entry);
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
||||
} catch {
|
||||
// Best-effort cache. Quota errors should not block XL Vask usage rows.
|
||||
}
|
||||
};
|
||||
|
||||
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
|
||||
const storage = safeSessionStorage();
|
||||
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
|
||||
if (cacheKey) {
|
||||
memoryCache.delete(cacheKey);
|
||||
try {
|
||||
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
|
||||
} catch {
|
||||
// Best-effort cache cleanup.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
memoryCache.clear();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Object.keys(storage)
|
||||
.filter((key) => key.startsWith(CACHE_PREFIX))
|
||||
.forEach((key) => storage.removeItem(key));
|
||||
} catch {
|
||||
// Best-effort cache cleanup.
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,19 @@
|
||||
const hasLinkedOrder = (object) => Number(object?.linked_order_id) > 0;
|
||||
|
||||
export const isUsageOrderAttachedToOrder = (object) => {
|
||||
if (hasLinkedOrder(object)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const automation = object?.automation;
|
||||
const automationStatus = automation?.status;
|
||||
if (
|
||||
["auto_accepted", "accepted"].includes(automationStatus) &&
|
||||
["attach_order", "create_order"].includes(automation?.action)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
|
||||
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
highlightUsageLogId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
import {computed, ref, watch} from 'vue';
|
||||
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
|
||||
@@ -28,6 +32,11 @@ import {
|
||||
filterUsageOrdersByAttachment,
|
||||
isUsageOrderAttachedToOrder
|
||||
} from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
||||
import {
|
||||
clearCachedXlvaskUsageAmount,
|
||||
getCachedXlvaskUsageAmount,
|
||||
setCachedXlvaskUsageAmount
|
||||
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
|
||||
|
||||
const redirectDepartmentOrderPage = (orderId, departmentId) => {
|
||||
// Send the user to the order page (In a new tab)
|
||||
@@ -204,25 +213,57 @@ const humanReadableTimeAgo = (dateString) => {
|
||||
return 'lige nu';
|
||||
};
|
||||
|
||||
const applyGeneratedContent = (object, data) => {
|
||||
const orderItems = Array.isArray(data?.order_items) ? data.order_items : [];
|
||||
const potentialDuplicates = Array.isArray(data?.potential_duplicates) ? data.potential_duplicates : [];
|
||||
|
||||
object.items = orderItems;
|
||||
if (potentialDuplicates.length > 0) {
|
||||
object.selectedDuplicateId = parseInt(potentialDuplicates[0].id);
|
||||
object.duplicates = potentialDuplicates;
|
||||
}
|
||||
if (data?.automation !== undefined) {
|
||||
object.automation = data.automation;
|
||||
}
|
||||
object.fast_link_key = undefined;
|
||||
object.loading = false;
|
||||
};
|
||||
|
||||
const applyCachedGeneratedContent = (object) => {
|
||||
const cached = getCachedXlvaskUsageAmount(object);
|
||||
if (!cached) {
|
||||
return false;
|
||||
}
|
||||
|
||||
applyGeneratedContent(object, cached);
|
||||
return true;
|
||||
};
|
||||
|
||||
const generateContent = (object) => {
|
||||
if (applyCachedGeneratedContent(object)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (!object.fast_link_key || object.loading) {
|
||||
return; // If no fast link key, do not generate content
|
||||
return Promise.resolve(); // If no fast link key, do not generate content
|
||||
}
|
||||
object.loading = true; // Set loading state to true
|
||||
// Generate content based on the object
|
||||
SessionUser.request(
|
||||
return SessionUser.request(
|
||||
'/modules/xlvask/services/usage/orders/fast-link?fast_link_key=' + object.fast_link_key,
|
||||
'GET'
|
||||
).then((response) => {
|
||||
const data = response.data.data;
|
||||
object.fast_link_key = undefined; // Clear the fast link key after use
|
||||
object.items = data.order_items;
|
||||
object.selectedDuplicateId = data.potential_duplicates?.length ? parseInt(data.potential_duplicates[0].id) : null;
|
||||
object.duplicates = data.potential_duplicates;
|
||||
object.loading = false; // Set loading state to false
|
||||
applyGeneratedContent(object, data);
|
||||
setCachedXlvaskUsageAmount(object, {
|
||||
order_items: object.items,
|
||||
potential_duplicates: object.duplicates,
|
||||
automation: object.automation,
|
||||
});
|
||||
}
|
||||
).catch((error) => {
|
||||
console.error('Error generating content:', error);
|
||||
object.loading = false;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -249,9 +290,20 @@ const getPriceFromOrderItems = (orderItems) => {
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const getObjectPrice = (object) => {
|
||||
if (doesObjectHaveItems(object)) {
|
||||
return getPriceFromOrderItems(object.items);
|
||||
}
|
||||
|
||||
const parsedAmount = Number(object?.total_net_amount ?? 0);
|
||||
return Number.isFinite(parsedAmount) ? parsedAmount : 0;
|
||||
};
|
||||
|
||||
const doesObjectHaveAmount = (object) => getObjectPrice(object) > 0;
|
||||
|
||||
const doesObjectPriceMatchPotentialDuplicate = (object, potentialDuplicate) => {
|
||||
// Check if the price of the object matches the potential duplicate
|
||||
return getPriceFromOrderItems(object.items) === potentialDuplicate.total_net_amount ?? 0;
|
||||
return getObjectPrice(object) === Number(potentialDuplicate?.total_net_amount ?? 0);
|
||||
};
|
||||
|
||||
const getPrimaryItemFromOrderItems = (orderItems) => {
|
||||
@@ -276,6 +328,14 @@ const getProductName = (orderItem) => {
|
||||
return product ? product.name : 'Ukendt produkt';
|
||||
};
|
||||
|
||||
const getObjectPrimaryProductName = (object) => {
|
||||
if (doesObjectHaveItems(object)) {
|
||||
return getProductName(getPrimaryItemFromOrderItems(object.items));
|
||||
}
|
||||
|
||||
return object?.xlvask_primary_product_name || object?.primary_product_name || '';
|
||||
};
|
||||
|
||||
const doesObjectHaveItems = (object) => {
|
||||
// Check if the object has items
|
||||
return object.items && Array.isArray(object.items) && object.items.length > 0;
|
||||
@@ -288,12 +348,12 @@ const automaticallyLoadContent = (object) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically load content for each object when the objects prop changes
|
||||
const isHighlightedUsageLog = (object) => Number(object?.id || 0) === Number(props.highlightUsageLogId || 0);
|
||||
|
||||
// Reuse cached detail payloads immediately, but only fetch one-time fast-link details on demand.
|
||||
watch(() => props.objects, (newObjects) => {
|
||||
newObjects.forEach(object => {
|
||||
if (object.fast_link_key && !object.loading) {
|
||||
generateContent(object);
|
||||
}
|
||||
applyCachedGeneratedContent(object);
|
||||
});
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
@@ -416,7 +476,7 @@ const isObjectAttachedToOrder = (object) => {
|
||||
const doesObjectHaveExactMatch = (object) => {
|
||||
// Check if the object has any duplicates with the same order price
|
||||
return object.duplicates?.some(duplicate => {
|
||||
return getPriceFromOrderItems(object.items) === duplicate.total_net_amount;
|
||||
return getObjectPrice(object) === Number(duplicate.total_net_amount ?? 0);
|
||||
}) || false;
|
||||
};
|
||||
|
||||
@@ -425,6 +485,151 @@ const doesObjectHaveDuplicates = (object) => {
|
||||
return object.duplicates && Array.isArray(object.duplicates) && object.duplicates.length > 0;
|
||||
};
|
||||
|
||||
const hasAutomationState = (object) => {
|
||||
const status = object?.automation?.status;
|
||||
return !!status && status !== 'none' && status !== 'failed';
|
||||
};
|
||||
|
||||
const isAutomationActionable = (object) => {
|
||||
return object?.automation?.can_accept || object?.automation?.can_deny;
|
||||
};
|
||||
|
||||
const getAutomationOrderId = (object) => {
|
||||
return object?.automation?.matched_order_id
|
||||
?? object?.automation?.created_order_id
|
||||
?? object?.automation?.candidate_order?.id
|
||||
?? null;
|
||||
};
|
||||
|
||||
const getAutomationLabel = (object) => {
|
||||
const automation = object?.automation ?? {};
|
||||
const orderId = getAutomationOrderId(object);
|
||||
const orderSuffix = orderId ? ` #${orderId}` : '';
|
||||
|
||||
if (automation.status === 'suggested') {
|
||||
return automation.action === 'create_order'
|
||||
? 'Foreslået: Opret ordre'
|
||||
: `Foreslået: Tilknyt ordre${orderSuffix}`;
|
||||
}
|
||||
|
||||
if (automation.status === 'auto_accepted') {
|
||||
return automation.action === 'create_order'
|
||||
? `Oprettet automatisk${orderSuffix}`
|
||||
: `Tilknyttet automatisk${orderSuffix}`;
|
||||
}
|
||||
|
||||
if (automation.status === 'accepted') {
|
||||
return automation.action === 'create_order'
|
||||
? `Oprettet${orderSuffix}`
|
||||
: `Accepteret${orderSuffix}`;
|
||||
}
|
||||
|
||||
if (automation.status === 'denied') {
|
||||
return 'Afvist';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const getAutomationReason = (object) => {
|
||||
return object?.automation?.reason || object?.automation?.error || '';
|
||||
};
|
||||
|
||||
const getAutomationConfidence = (object) => {
|
||||
const confidence = Number(object?.automation?.confidence ?? 0);
|
||||
if (!Number.isFinite(confidence) || confidence <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${Math.round(confidence * 100)}% sikkerhed`;
|
||||
};
|
||||
|
||||
const getAutomationTagClass = (object) => {
|
||||
const status = object?.automation?.status;
|
||||
if (status === 'auto_accepted' || status === 'accepted') {
|
||||
return 'is-success';
|
||||
}
|
||||
if (status === 'denied') {
|
||||
return 'is-light';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return 'is-danger';
|
||||
}
|
||||
return object?.automation?.source === 'openai' ? 'is-info' : 'is-warning';
|
||||
};
|
||||
|
||||
const getAutomationMessageClass = (object) => {
|
||||
const status = object?.automation?.status;
|
||||
if (status === 'auto_accepted' || status === 'accepted') {
|
||||
return 'is-success';
|
||||
}
|
||||
if (status === 'denied') {
|
||||
return 'is-light';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return 'is-danger';
|
||||
}
|
||||
return object?.automation?.source === 'openai' ? 'is-info' : 'is-warning';
|
||||
};
|
||||
|
||||
const getAutomationIconClass = (object) => {
|
||||
const automation = object?.automation ?? {};
|
||||
if (automation.status === 'denied') {
|
||||
return 'fa-ban';
|
||||
}
|
||||
if (automation.status === 'failed') {
|
||||
return 'fa-exclamation-triangle';
|
||||
}
|
||||
if (automation.action === 'create_order') {
|
||||
return 'fa-plus-circle';
|
||||
}
|
||||
return 'fa-link';
|
||||
};
|
||||
|
||||
const setAutomationDecision = (object, decision) => {
|
||||
if (!object?.id || object.automationLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
object.automationLoading = true;
|
||||
SessionUser.request(
|
||||
`/modules/xlvask/services/usage/orders/${object.id}/automation/${decision}`,
|
||||
'POST',
|
||||
{
|
||||
suggestion_id: object.automation?.id ?? null,
|
||||
}
|
||||
).then((response) => {
|
||||
object.automation = response.data.data;
|
||||
clearCachedXlvaskUsageAmount(object);
|
||||
window.dispatchEvent(new CustomEvent('xlvask-usage-order-updated', {
|
||||
detail: {
|
||||
usageLogId: object.id,
|
||||
decision,
|
||||
},
|
||||
}));
|
||||
}).catch((error) => {
|
||||
Swal.fire({
|
||||
title: 'Fejl',
|
||||
text: decision === 'accept'
|
||||
? 'Forslaget kunne ikke accepteres.'
|
||||
: 'Forslaget kunne ikke afvises.',
|
||||
icon: 'error',
|
||||
confirmButtonText: 'OK',
|
||||
});
|
||||
console.error(error);
|
||||
}).finally(() => {
|
||||
object.automationLoading = false;
|
||||
});
|
||||
};
|
||||
|
||||
const acceptAutomation = (object) => {
|
||||
setAutomationDecision(object, 'accept');
|
||||
};
|
||||
|
||||
const denyAutomation = (object) => {
|
||||
setAutomationDecision(object, 'deny');
|
||||
};
|
||||
|
||||
const filteredObjects = computed(() => {
|
||||
return filterUsageOrdersByAttachment(props.objects, props.showOnlyUnattachedVehicle);
|
||||
});
|
||||
@@ -539,47 +744,100 @@ const filteredObjects = computed(() => {
|
||||
</tfoot>
|
||||
</table>
|
||||
<template v-for="object in filteredObjects" :key="object.id">
|
||||
<WhiteBoxCard v-bind:loading="object.loading ?? false" :toggleable="true" @mouseover="automaticallyLoadContent(object)" @click="automaticallyLoadContent(object)" :default-open="object.isOpen ?? false">
|
||||
<WhiteBoxCard
|
||||
v-bind:loading="object.loading ?? false"
|
||||
:toggleable="true"
|
||||
:class="{ 'xlvask-usage-card--highlighted': isHighlightedUsageLog(object) }"
|
||||
@mouseover="automaticallyLoadContent(object)"
|
||||
@click="automaticallyLoadContent(object)"
|
||||
:default-open="isHighlightedUsageLog(object) || (object.isOpen ?? false)"
|
||||
>
|
||||
<template #header>
|
||||
<p class="card-header-title">
|
||||
<!-- Registration Number -->
|
||||
<span class="has-text-weight-bold">{{ object.reg_1 }}</span>
|
||||
<!-- Time Ago -->
|
||||
<span class="tag is-light is-size-7 ml-2">
|
||||
{{ humanReadableTimeAgo(object.created_at) }}
|
||||
</span>
|
||||
<!-- Primary Item Name -->
|
||||
<span class="tag is-light is-size-7 ml-2" v-if="doesObjectHaveItems(object)">
|
||||
{{ getProductName(getPrimaryItemFromOrderItems(object.items)) }}
|
||||
</span>
|
||||
<!-- Price -->
|
||||
<span class="tag is-light is-size-7 ml-2" v-if="doesObjectHaveItems(object)">
|
||||
{{ SessionUser.functions.currency.toLocal(getPriceFromOrderItems(object.items).toFixed(2)) }}
|
||||
</span>
|
||||
<!-- Linked to order status -->
|
||||
<span class="tag is-light is-size-7 ml-2" :class="{ 'is-grey': !isObjectAttachedToOrder(object), 'is-success': isObjectAttachedToOrder(object) }">
|
||||
<span class="icon is-small"><i class="fas" :class="isObjectAttachedToOrder(object) ? 'fa-link' : 'fa-unlink'"></i></span>
|
||||
<span class="has-text-weight-bold">{{ isObjectAttachedToOrder(object) ? 'Tilknyttet ordre' : 'Ikke tilknyttet ordre' }}</span>
|
||||
</span>
|
||||
<!-- Potential duplicates found?->Exact match found ?-->
|
||||
<span class="tag is-light is-size-7 ml-2" :class="{ 'is-success': doesObjectHaveExactMatch(object), 'is-warning': !doesObjectHaveExactMatch(object) }" v-if="doesObjectHaveDuplicates(object)">
|
||||
<span class="icon is-small"><i class="fas" :class="doesObjectHaveExactMatch(object) ? 'fa-check' : 'fa-exclamation-triangle'"></i></span>
|
||||
<span class="has-text-weight-bold">{{ (doesObjectHaveExactMatch(object) ? SessionUser.objects.global.language.price_match : SessionUser.objects.global.language.price_unmatch) }}</span>
|
||||
</span>
|
||||
<!-- Department and Lane -->
|
||||
<span class="tags has-addons ml-2">
|
||||
<span class="tag">{{ getDepartmentName(object.department_id) }}</span>
|
||||
<span class="tag is-info is-light">{{ object.lane }}</span>
|
||||
</span>
|
||||
</p>
|
||||
<p class="card-header-title is-justify-content-end">
|
||||
<span class="has-text-weight-bold">{{ object.customer_name }}</span>
|
||||
</p>
|
||||
<div class="card-header-title xlvask-usage-card-header">
|
||||
<div class="xlvask-usage-card-main">
|
||||
<div class="xlvask-usage-card-primary">
|
||||
<span class="has-text-weight-bold xlvask-usage-registration">{{ object.reg_1 }}</span>
|
||||
<span class="tag is-light is-size-7 xlvask-usage-chip">
|
||||
{{ humanReadableTimeAgo(object.created_at) }}
|
||||
</span>
|
||||
<span class="tag is-light is-size-7 xlvask-usage-chip" v-if="getObjectPrimaryProductName(object)">
|
||||
{{ getObjectPrimaryProductName(object) }}
|
||||
</span>
|
||||
<span class="tag is-light is-size-7 xlvask-usage-chip xlvask-usage-price" v-if="doesObjectHaveAmount(object)">
|
||||
{{ SessionUser.functions.currency.toLocal(getObjectPrice(object).toFixed(2)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="xlvask-usage-card-status">
|
||||
<span class="tag is-light is-size-7 xlvask-usage-chip" :class="{ 'is-grey': !isObjectAttachedToOrder(object), 'is-success': isObjectAttachedToOrder(object) }">
|
||||
<span class="icon is-small"><i class="fas" :class="isObjectAttachedToOrder(object) ? 'fa-link' : 'fa-unlink'"></i></span>
|
||||
<span class="has-text-weight-bold xlvask-usage-chip-text">{{ isObjectAttachedToOrder(object) ? 'Tilknyttet ordre' : 'Ikke tilknyttet ordre' }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="hasAutomationState(object)"
|
||||
class="tag is-light is-size-7 xlvask-usage-chip xlvask-usage-automation-chip"
|
||||
:class="getAutomationTagClass(object)"
|
||||
:data-testid="'xlvask-automation-suggestion-' + object.id"
|
||||
>
|
||||
<span class="icon is-small"><i class="fas" :class="getAutomationIconClass(object)"></i></span>
|
||||
<span class="has-text-weight-bold xlvask-usage-chip-text">{{ getAutomationLabel(object) }}</span>
|
||||
</span>
|
||||
<span class="tag is-light is-size-7 xlvask-usage-chip xlvask-usage-warning-chip" :class="{ 'is-success': doesObjectHaveExactMatch(object), 'is-warning': !doesObjectHaveExactMatch(object) }" v-if="doesObjectHaveDuplicates(object)">
|
||||
<span class="icon is-small"><i class="fas" :class="doesObjectHaveExactMatch(object) ? 'fa-check' : 'fa-exclamation-triangle'"></i></span>
|
||||
<span class="has-text-weight-bold xlvask-usage-chip-text">{{ (doesObjectHaveExactMatch(object) ? SessionUser.objects.global.language.price_match : SessionUser.objects.global.language.price_unmatch) }}</span>
|
||||
</span>
|
||||
<span class="tags has-addons xlvask-usage-location-tags">
|
||||
<span class="tag xlvask-usage-chip">{{ getDepartmentName(object.department_id) }}</span>
|
||||
<span class="tag is-info is-light xlvask-usage-chip">{{ object.lane }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="xlvask-usage-card-customer" :title="object.customer_name">
|
||||
{{ object.customer_name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="columns">
|
||||
<div class="column is-eight">
|
||||
<div class="columns xlvask-usage-content-columns">
|
||||
<div class="column is-10 xlvask-usage-main-column">
|
||||
<div class="content">
|
||||
<article
|
||||
v-if="hasAutomationState(object)"
|
||||
class="message mb-3"
|
||||
:class="getAutomationMessageClass(object)"
|
||||
>
|
||||
<div class="message-body">
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
|
||||
<div>
|
||||
<p class="has-text-weight-bold mb-1">
|
||||
<span class="icon is-small"><i class="fas" :class="getAutomationIconClass(object)"></i></span>
|
||||
<span>{{ getAutomationLabel(object) }}</span>
|
||||
</p>
|
||||
<p v-if="getAutomationReason(object)" class="mb-1">{{ getAutomationReason(object) }}</p>
|
||||
<p v-if="getAutomationConfidence(object)" class="is-size-7 has-text-grey">{{ getAutomationConfidence(object) }}</p>
|
||||
</div>
|
||||
<div class="buttons mb-0" v-if="isAutomationActionable(object)">
|
||||
<button
|
||||
class="button is-small is-success"
|
||||
:class="{ 'is-loading': object.automationLoading }"
|
||||
:disabled="object.automationLoading"
|
||||
:data-testid="'xlvask-automation-accept-' + object.id"
|
||||
@click.stop="acceptAutomation(object)"
|
||||
>
|
||||
Accepter
|
||||
</button>
|
||||
<button
|
||||
class="button is-small is-light"
|
||||
:class="{ 'is-loading': object.automationLoading }"
|
||||
:disabled="object.automationLoading"
|
||||
:data-testid="'xlvask-automation-deny-' + object.id"
|
||||
@click.stop="denyAutomation(object)"
|
||||
>
|
||||
Afvis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div class="message" :class="{ 'is-warning': (!isObjectAttachedToOrder(object)), 'is-success': isObjectAttachedToOrder(object) }">
|
||||
<div class="message-body">
|
||||
<template v-if="object.wash_id">
|
||||
@@ -615,9 +873,8 @@ const filteredObjects = computed(() => {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!object.loading">{{generateContent(object)}}</span>
|
||||
<template v-if="object.items">
|
||||
<div class="columns">
|
||||
<div class="columns xlvask-order-comparison-columns">
|
||||
<div class="column" :class="{ 'is-12': (!object.duplicates?.length && !isComparingOrders(object)), 'is-6': (object.duplicates?.length && isComparingOrders(object)) }">
|
||||
<div class="field">
|
||||
<input class="input" type="text" readonly :value="getDepartmentName(object.department_id) + ' - ' + object.created_at + ' (' + SessionUser.objects.global.language.generated + ')'"/>
|
||||
@@ -666,7 +923,7 @@ const filteredObjects = computed(() => {
|
||||
<span><br>{{ object.wash_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<div class="column is-2 xlvask-usage-actions-column">
|
||||
<div class="content">
|
||||
<div class="buttons">
|
||||
<template v-if="isObjectAttachedToOrder(object)">
|
||||
@@ -729,4 +986,187 @@ const filteredObjects = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.xlvask-usage-card--highlighted {
|
||||
border-left: 4px solid #3273dc;
|
||||
box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.16);
|
||||
}
|
||||
|
||||
.xlvask-usage-card-header {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 0.6rem 1rem;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(9rem, 15rem);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xlvask-usage-card-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xlvask-usage-card-primary,
|
||||
.xlvask-usage-card-status {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xlvask-usage-registration {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xlvask-usage-card-primary > .tag,
|
||||
.xlvask-usage-card-status > .tag,
|
||||
.xlvask-usage-card-status > .tags {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.xlvask-usage-chip {
|
||||
height: auto;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
min-height: 1.6rem;
|
||||
min-width: 0;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xlvask-usage-chip .icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.xlvask-usage-chip-text {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xlvask-usage-automation-chip .xlvask-usage-chip-text {
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.xlvask-usage-warning-chip .xlvask-usage-chip-text {
|
||||
max-width: 15rem;
|
||||
}
|
||||
|
||||
.xlvask-usage-location-tags {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xlvask-usage-location-tags .tag:first-child {
|
||||
display: block;
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xlvask-usage-card-customer {
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.xlvask-usage-content-columns,
|
||||
.xlvask-order-comparison-columns {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xlvask-usage-main-column,
|
||||
.xlvask-usage-actions-column,
|
||||
.xlvask-order-comparison-columns > .column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xlvask-usage-main-column .content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xlvask-order-comparison-columns .input,
|
||||
.xlvask-order-comparison-columns :deep(table) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.xlvask-order-comparison-columns :deep(.table-container) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1500px) {
|
||||
.xlvask-usage-card-header {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(8rem, 12rem);
|
||||
}
|
||||
|
||||
.xlvask-usage-automation-chip .xlvask-usage-chip-text {
|
||||
max-width: 13rem;
|
||||
}
|
||||
|
||||
.xlvask-usage-warning-chip .xlvask-usage-chip-text,
|
||||
.xlvask-usage-location-tags .tag:first-child {
|
||||
max-width: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1350px) {
|
||||
.xlvask-usage-card-header {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.xlvask-usage-card-customer {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.xlvask-order-comparison-columns {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xlvask-order-comparison-columns > .column {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1215px) {
|
||||
.xlvask-usage-content-columns {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xlvask-usage-actions-column .buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.xlvask-usage-actions-column .button {
|
||||
flex: 1 1 10rem;
|
||||
margin-bottom: 0;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.xlvask-usage-chip-text,
|
||||
.xlvask-usage-automation-chip .xlvask-usage-chip-text,
|
||||
.xlvask-usage-warning-chip .xlvask-usage-chip-text,
|
||||
.xlvask-usage-location-tags .tag:first-child {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,7 +50,7 @@ const handleFileUpload = async (event) => {
|
||||
await fetchAttachments();
|
||||
Swal.fire({
|
||||
icon: "success",
|
||||
title: t('modals.success'),
|
||||
title: t('common.success'),
|
||||
text: t('modals.file_uploaded'),
|
||||
timer: 1500,
|
||||
showConfirmButton: false,
|
||||
@@ -59,7 +59,7 @@ const handleFileUpload = async (event) => {
|
||||
console.error("Error uploading attachment:", error);
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: t('modals.error'),
|
||||
title: t('common.error'),
|
||||
text: t('modals.upload_error'),
|
||||
});
|
||||
} finally {
|
||||
@@ -96,7 +96,7 @@ const deleteAttachment = async (attachmentId) => {
|
||||
console.error("Error deleting attachment:", error);
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: t('modals.error'),
|
||||
title: t('common.error'),
|
||||
text: t('modals.delete_error'),
|
||||
});
|
||||
}
|
||||
@@ -117,7 +117,7 @@ const downloadAttachment = async (attachmentId) => {
|
||||
console.error("Error downloading attachment:", error);
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: t('modals.error'),
|
||||
title: t('common.error'),
|
||||
text: t('modals.download_error'),
|
||||
});
|
||||
}
|
||||
@@ -145,7 +145,7 @@ onMounted(() => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('modals.filename') }}</th>
|
||||
<th class="has-text-right">{{ t('modals.actions') }}</th>
|
||||
<th class="has-text-right">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -8,6 +8,10 @@ const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = use
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { ref, watch } from "vue";
|
||||
import SelfServeTaskAttachmentsModal from "@/components/displays/department/tables/SelfServeTaskAttachmentsModal.vue";
|
||||
import {
|
||||
SELF_SERVE_TASK_BUTTON_OPTIONS,
|
||||
normalizeSelfServeTaskButtons,
|
||||
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||
|
||||
// Define the props
|
||||
const props = defineProps({
|
||||
@@ -36,24 +40,15 @@ const closeAttachmentsModal = () => {
|
||||
|
||||
const editButtonsFunction = (id, column, value, onAfterSubmit) => {
|
||||
editingButtonsId.value = id;
|
||||
try {
|
||||
const parsed = JSON.stringify(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
currentButtonsSelection.value = parsed;
|
||||
} else {
|
||||
console.warn('Parsed buttons value is not an array:', parsed);
|
||||
currentButtonsSelection.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error parsing buttons value:', e);
|
||||
currentButtonsSelection.value = [];
|
||||
}
|
||||
currentButtonsSelection.value = normalizeSelfServeTaskButtons(value);
|
||||
showButtonsEditModal.value = true;
|
||||
};
|
||||
|
||||
const saveButtonsEdit = async () => {
|
||||
console.warn('Saving buttons with selection:', currentButtonsSelection.value);
|
||||
await SessionUser.objects.self_serve_tasks.set.buttons(editingButtonsId.value, currentButtonsSelection.value);
|
||||
await SessionUser.objects.self_serve_tasks.set.buttons(
|
||||
editingButtonsId.value,
|
||||
normalizeSelfServeTaskButtons(currentButtonsSelection.value)
|
||||
);
|
||||
showButtonsEditModal.value = false;
|
||||
loadList();
|
||||
};
|
||||
@@ -195,7 +190,7 @@ const getScopeLabel = (object) => {
|
||||
<td>{{ object.order_priority }}</td>
|
||||
<td>{{ object.task }}</td>
|
||||
<td>{{ object.description }}</td>
|
||||
<td>{{ object.condition_id ? SessionUser.objects.self_serve_conditions.functions.getConditionName(object.condition_id) : $t('global.all') }}</td>
|
||||
<td>{{ object.condition_id ? SessionUser.objects.self_serve_conditions.functions.getConditionName(object.condition_id) : $t('common.all') }}</td>
|
||||
<td>{{ getScopeLabel(object) }}</td>
|
||||
<td>{{ SessionUser.objects.self_serve_tasks.columns.services.parse(object.services) }}</td>
|
||||
<td>{{ SessionUser.objects.self_serve_tasks.columns.buttons.parse(object.buttons) }}</td>
|
||||
@@ -237,15 +232,15 @@ const getScopeLabel = (object) => {
|
||||
<button class="delete" @click="cancelButtonsEdit" aria-label="close"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div v-for="n in 12" :key="n" class="field mb-3">
|
||||
<div v-for="option in SELF_SERVE_TASK_BUTTON_OPTIONS" :key="String(option.id)" class="field mb-3">
|
||||
<input
|
||||
class="is-checkradio"
|
||||
type="checkbox"
|
||||
:id="`btn-${n-1}`"
|
||||
:id="`btn-${option.id}`"
|
||||
v-model="currentButtonsSelection"
|
||||
:value="n-1"
|
||||
:value="option.id"
|
||||
>
|
||||
<label :for="`btn-${n-1}`">Button {{ n-1 }}</label>
|
||||
<label :for="`btn-${option.id}`">{{ option.name }}</label>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
|
||||
@@ -6,7 +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 {
|
||||
buildSelfServeDynamicImageUrl,
|
||||
getSelfServeDynamicImageButtonsToPress,
|
||||
getSelfServeDynamicImageThumbPosition,
|
||||
} from "@/services/selfServeDynamicImage.js";
|
||||
|
||||
const { t: $t } = useI18n();
|
||||
|
||||
@@ -61,6 +65,7 @@ const {
|
||||
syncVehicleAnswer,
|
||||
clearVehicleAnswers,
|
||||
evaluateCondition,
|
||||
downloadAttachment,
|
||||
} = useSelfServeLogic();
|
||||
|
||||
const emptyCompletedTasks = computed(() => ({}));
|
||||
@@ -136,43 +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 Set();
|
||||
|
||||
activeTasks.value.forEach((task) => {
|
||||
if (!Array.isArray(task?.buttons)) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.buttons.forEach((button) => {
|
||||
const buttonId = parseInt(button);
|
||||
if (!Number.isNaN(buttonId) && buttonId >= 0 && buttonId <= 11) {
|
||||
buttonIds.add(buttonId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return [...buttonIds];
|
||||
});
|
||||
|
||||
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
|
||||
@@ -185,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(() => (
|
||||
@@ -266,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",
|
||||
@@ -321,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>
|
||||
|
||||
@@ -338,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>
|
||||
@@ -350,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>
|
||||
@@ -362,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>
|
||||
@@ -372,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 }})
|
||||
@@ -384,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>
|
||||
|
||||
@@ -401,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
|
||||
@@ -412,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"
|
||||
@@ -435,14 +398,14 @@ watch(dynamicImageUrl, () => {
|
||||
:completedTasks="emptyCompletedTasks"
|
||||
:show-checkboxes="false"
|
||||
@toggle-task="() => {}"
|
||||
@download-attachment="() => {}"
|
||||
@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>
|
||||
@@ -453,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"
|
||||
|
||||
@@ -54,7 +54,7 @@ const props = defineProps({
|
||||
:loadList="loadList"
|
||||
column="value"
|
||||
:edit-function="SessionUser.objects.self_serve_vehicle_conditions.showEditObjectFieldForm"
|
||||
:parse-function="(val) => val ? t('global.yes') : t('global.no')"
|
||||
:parse-function="(val) => val ? t('common.yes') : t('common.no')"
|
||||
:permission-check-function="SessionUser.canAccessAdmin"
|
||||
/>
|
||||
<EditableTableColumn
|
||||
|
||||
@@ -34,10 +34,10 @@ if (departments.value.length === 0) {
|
||||
<th>{{ $t('objects.columns.department') }}</th>
|
||||
<th>{{ $t('tables.bookings.reg_tractor') }}</th>
|
||||
<th>{{ $t('tables.bookings.reg_trailer') }}</th>
|
||||
<th>{{ $t('objects.columns.reference') }}</th>
|
||||
<th>{{ $t('common.reference') }}</th>
|
||||
<th>{{ $t('objects.columns.notes') }}</th>
|
||||
<th>{{ $t('objects.columns.date') }}</th>
|
||||
<th>{{ $t('objects.columns.status') }}</th>
|
||||
<th>{{ $t('common.date') }}</th>
|
||||
<th>{{ $t('common.status') }}</th>
|
||||
<th>{{ $t('objects.bookings.columns.contact_email') }}</th>
|
||||
<th>{{ $t('objects.bookings.columns.wash_certificate_email') }}</th>
|
||||
<th>{{ $t('tables.actions') }}</th>
|
||||
@@ -59,7 +59,7 @@ if (departments.value.length === 0) {
|
||||
<td>{{ booking.washCertificateEmail }}</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('global.show') }}</button>
|
||||
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('common.show') }}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -598,7 +598,7 @@ onBeforeUnmount(() => {
|
||||
data-testid="draft-order-assign-cancel"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ t("global.cancel") }}
|
||||
{{ t("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button is-link"
|
||||
|
||||
@@ -107,7 +107,7 @@ loadUser();
|
||||
<div class="modal-background" @click="closeModal"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{getUserKey('customer_name', t('modals.unknown_customer'), t('modals.loading'))}}</p>
|
||||
<p class="modal-card-title">{{getUserKey('customer_name', t('modals.unknown_customer'), t('common.loading'))}}</p>
|
||||
<button class="delete" aria-label="close" @click="closeModal"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
@@ -150,7 +150,7 @@ loadUser();
|
||||
<p>{{ t('modals.fetching_data') }}</p>
|
||||
<p>{{ t('modals.please_wait') }}</p>
|
||||
</div>
|
||||
<progress class="progress is-small is-dark" max="100">{{ t('modals.loading') }}</progress>
|
||||
<progress class="progress is-small is-dark" max="100">{{ t('common.loading') }}</progress>
|
||||
</template>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
|
||||
@@ -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;
|
||||
@@ -8,8 +8,20 @@ const { t } = useI18n();
|
||||
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const canExport = computed(() => typeof paginatedList?.exportToExcel === 'function');
|
||||
const props = defineProps({
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -54,25 +66,25 @@ const handleDocumentClick = (event: MouseEvent) => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Top row: Reload, Search -->
|
||||
<div class="columns is-vcentered is-multiline pagination-general-search-reload">
|
||||
<div class="column pagination-general-search-reload__search-column">
|
||||
<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">
|
||||
@@ -127,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";
|
||||
|
||||
@@ -23,6 +15,22 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
showFilters: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
hidePagination: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const isSmall = ref(window.innerWidth < 1024);
|
||||
</script>
|
||||
@@ -31,18 +39,31 @@ const isSmall = ref(window.innerWidth < 1024);
|
||||
<div data-disable-auto-excel-export="1">
|
||||
<!-- Label -->
|
||||
<h2 class="title is-4 mb-4">{{ label }}</h2>
|
||||
<slot name="description"></slot>
|
||||
<!-- Search & reload -->
|
||||
<PaginationDisplayGeneralSearchReload>
|
||||
<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>
|
||||
</template>
|
||||
</PaginationDisplayGeneralSearchReload>
|
||||
<slot name="afterSearch"></slot>
|
||||
<!-- Filters -->
|
||||
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
||||
<PaginationDisplay
|
||||
v-if="showFilters"
|
||||
data-testid="table-labeled-pagination-filters"
|
||||
:metaItemsPerPage="metaItemsPerPage"
|
||||
:loadFunction="loadList"
|
||||
:isLoading="isLoading"
|
||||
:setMetaItemsPerPage="setMetaItemsPerPage"
|
||||
>
|
||||
<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>
|
||||
@@ -54,9 +75,15 @@ const isSmall = ref(window.innerWidth < 1024);
|
||||
<slot name="default"></slot>
|
||||
</section>
|
||||
<!-- Pagination -->
|
||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
||||
<PaginationNavigation
|
||||
v-if="!props.hidePagination"
|
||||
:currentPage="metaCurrentPage"
|
||||
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
|
||||
:loadFunction="loadList"
|
||||
:setPage="setPage"
|
||||
:isLoading="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -24,8 +24,17 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
import { provide } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
usePaginatedList,
|
||||
PaginatedListKey,
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const paginatedList = usePaginatedList();
|
||||
provide(PaginatedListKey, paginatedList);
|
||||
|
||||
const {
|
||||
isLoaded,
|
||||
isLoading,
|
||||
list,
|
||||
@@ -42,7 +51,7 @@ import {
|
||||
setOrder,
|
||||
hideSearchField,
|
||||
setHideSearchField,
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
} = paginatedList;
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
|
||||
@@ -7,9 +7,21 @@ const props = defineProps({
|
||||
type: Number, default: 0
|
||||
}, hideSearch: {
|
||||
type: Boolean, default: false
|
||||
}, title: {
|
||||
type: String, default: ""
|
||||
}, initialDateFrom: {
|
||||
type: String, default: ""
|
||||
}, initialDateTo: {
|
||||
type: String, default: ""
|
||||
}, inheritPeriodFilters: {
|
||||
type: Boolean, default: false
|
||||
}, loadAllAtOnce: {
|
||||
type: Boolean, default: false
|
||||
}, highlightUsageLogId: {
|
||||
type: Number, default: 0
|
||||
}
|
||||
})
|
||||
import {computed, ref } from "vue";
|
||||
import {computed, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
isLoading,
|
||||
@@ -34,13 +46,40 @@ import PaginationDisplay from "@/components/displays/pagination/PaginationDispla
|
||||
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
|
||||
import PaginationDisplayTemplateDates
|
||||
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
|
||||
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BSwitch } from "buefy";
|
||||
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
const SELFWASH_PERIOD_ALL_LIMIT = 10000;
|
||||
setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||
if (props.loadAllAtOnce) {
|
||||
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
|
||||
}
|
||||
const parseInitialDate = (value) => {
|
||||
if (!value) {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
};
|
||||
|
||||
const applyInitialPeriodFilters = () => {
|
||||
if (props.initialDateFrom) {
|
||||
setFilter("StartTime-date_from", props.initialDateFrom, false);
|
||||
}
|
||||
|
||||
if (props.initialDateTo) {
|
||||
setFilter("StartTime-date_to", props.initialDateTo, false);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowLocalFilters = computed(() => !props.inheritPeriodFilters);
|
||||
|
||||
const titleText = computed(() => props.title || SessionUser.objects.orders.meta.title);
|
||||
// If the customer filter is set, filter the orders by the customer number
|
||||
if (props.setCustomerFilter > 0) {
|
||||
setFilter("customer_number", props.setCustomerFilter);
|
||||
@@ -62,10 +101,6 @@ if (router.currentRoute.value.params.departmentId) {
|
||||
} else {
|
||||
setOrder("StartTime", "desc");
|
||||
}
|
||||
// Load the list automatically if the autoLoad prop is set
|
||||
if (props.autoLoad) {
|
||||
loadList();
|
||||
}
|
||||
const isImportLoading = ref(false);
|
||||
|
||||
const importUsageButton = async () => {
|
||||
@@ -90,8 +125,8 @@ const importUsageButton = async () => {
|
||||
isImportLoading.value = false;
|
||||
}
|
||||
};
|
||||
const dateFrom = ref(new Date());
|
||||
const dateTo = ref(new Date());
|
||||
const dateFrom = ref(parseInitialDate(props.initialDateFrom));
|
||||
const dateTo = ref(parseInitialDate(props.initialDateTo));
|
||||
|
||||
const parsedDate = (date) => {
|
||||
return new Date(date);
|
||||
@@ -127,6 +162,29 @@ const actions = {
|
||||
}
|
||||
};
|
||||
|
||||
applyInitialPeriodFilters();
|
||||
// Load the list automatically if the autoLoad prop is set
|
||||
if (props.autoLoad) {
|
||||
loadList();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.initialDateFrom, props.initialDateTo],
|
||||
([nextDateFrom, nextDateTo], [previousDateFrom, previousDateTo]) => {
|
||||
if (nextDateFrom === previousDateFrom && nextDateTo === previousDateTo) {
|
||||
return;
|
||||
}
|
||||
|
||||
dateFrom.value = parseInitialDate(nextDateFrom);
|
||||
dateTo.value = parseInitialDate(nextDateTo);
|
||||
applyInitialPeriodFilters();
|
||||
|
||||
if (props.autoLoad) {
|
||||
loadList();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const showOnlyUnattachedVehicle = ref(false);
|
||||
const visibleObjectsCount = computed(() => {
|
||||
const currentList = Array.isArray(list.value) ? list.value : [];
|
||||
@@ -142,7 +200,7 @@ const visibleObjectsCount = computed(() => {
|
||||
<div class="level mb-3">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<h1 class="title is-4">{{SessionUser.objects.orders.meta.title}} ({{SessionUser.objects.global.language.synchronized}})</h1>
|
||||
<h1 class="title is-4">{{ titleText }} ({{SessionUser.objects.global.language.synchronized}})</h1>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<h2 class="subtitle is-6">{{SessionUser.objects.global.language.showing}} {{ visibleObjectsCount }} {{SessionUser.objects.global.language.showing_of_separator}} {{ metaTotalItems }} {{SessionUser.objects.orders.meta.title.toLowerCase()}}</h2>
|
||||
@@ -157,8 +215,8 @@ const visibleObjectsCount = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_transactions')" v-if="!hideSearchField"/>
|
||||
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_transactions')" v-if="!hideSearchField && shouldShowLocalFilters"/>
|
||||
<PaginationDisplay v-if="shouldShowLocalFilters" :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
||||
<template #paginationColumns>
|
||||
<!-- Sort by created_at -->
|
||||
<div class="column is-narrow my-3">
|
||||
@@ -186,7 +244,7 @@ const visibleObjectsCount = computed(() => {
|
||||
type="is-link"
|
||||
v-model="showOnlyUnattachedVehicle"
|
||||
>
|
||||
Vis kun ikke tilknyttet køretøj
|
||||
Vis kun ikke tilknyttede vaske
|
||||
</b-switch>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,11 +262,20 @@ const visibleObjectsCount = computed(() => {
|
||||
-->
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
<ShowErrorField v-else error="paginatedGetRequest"/>
|
||||
<XlvaskUsageOrdersTable
|
||||
:objects="list"
|
||||
:show-only-unattached-vehicle="showOnlyUnattachedVehicle"
|
||||
:highlight-usage-log-id="props.highlightUsageLogId"
|
||||
/>
|
||||
<PaginationNavigation
|
||||
v-if="!props.loadAllAtOnce"
|
||||
:currentPage="metaCurrentPage"
|
||||
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
|
||||
:loadFunction="loadList"
|
||||
:setPage="setPage"
|
||||
:isLoading="isLoading"
|
||||
/>
|
||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+30
-47
@@ -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">
|
||||
@@ -93,7 +91,7 @@ if (props.autoLoad) {
|
||||
data-testid="superuser-complaints-department-filter"
|
||||
@change="onDepartmentFilterChange($event.target.value)"
|
||||
>
|
||||
<option value="*">{{ t('global.all') }}</option>
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option
|
||||
v-for="department in departments"
|
||||
:key="department.id"
|
||||
@@ -112,7 +110,7 @@ if (props.autoLoad) {
|
||||
<div class="select">
|
||||
<select
|
||||
data-testid="superuser-complaints-order-direction"
|
||||
@change="setOrder('created_at', $event.target.value); loadList();"
|
||||
@change="onOrderDirectionChange($event.target.value)"
|
||||
>
|
||||
<option value="desc" selected>{{ t('pagination.descending') }}</option>
|
||||
<option value="asc">{{ t('pagination.ascending') }}</option>
|
||||
@@ -121,26 +119,11 @@ if (props.autoLoad) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
|
||||
<CustomerComplaintsTable :objects="list" />
|
||||
|
||||
<PaginationNavigation
|
||||
:currentPage="metaCurrentPage"
|
||||
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
|
||||
:loadFunction="loadList"
|
||||
:setPage="setPage"
|
||||
:isLoading="isLoading"
|
||||
/>
|
||||
|
||||
<LoadButtonWhileAwait
|
||||
class="is-dark"
|
||||
:isLoading="isLoading"
|
||||
:loadFunction="loadList"
|
||||
icon="fas fa-sync-alt"
|
||||
>
|
||||
{{ t('pagination.reload') }}
|
||||
</LoadButtonWhileAwait>
|
||||
<template #default>
|
||||
<CustomerComplaintsTable :objects="list" />
|
||||
</template>
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+20
-46
@@ -1,45 +1,27 @@
|
||||
<script setup>
|
||||
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
usePaginatedList,
|
||||
PaginatedListKey
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
import { provide } from "vue";
|
||||
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
|
||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const props = defineProps({
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autoLoad: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const paginatedList = usePaginatedList();
|
||||
provide(PaginatedListKey, paginatedList);
|
||||
const {
|
||||
isLoaded,
|
||||
isLoading,
|
||||
list,
|
||||
loadList,
|
||||
metaCurrentPage,
|
||||
metaItemsPerPage,
|
||||
metaTotalItems,
|
||||
setEndpoint,
|
||||
setMetaItemsPerPage,
|
||||
setPage,
|
||||
metaSearch,
|
||||
search,
|
||||
setFilter,
|
||||
setOrder,
|
||||
hideSearchField,
|
||||
setHideSearchField,
|
||||
} = paginatedList;
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
||||
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { list, loadList, setEndpoint, hideSearchField, setHideSearchField } = paginatedList;
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
setEndpoint("/customers", false);
|
||||
|
||||
// Hide the search field, if the hideSearch prop is set
|
||||
if (props.hideSearch) {
|
||||
setHideSearchField(true);
|
||||
}
|
||||
@@ -47,20 +29,12 @@ if (props.hideSearch) {
|
||||
if (props.autoLoad) {
|
||||
loadList();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_customers')" v-if="!hideSearchField"/>
|
||||
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
||||
<template #paginationColumns>
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
<CustomersTable :objects="list" />
|
||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
||||
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
|
||||
<TableLabeledPagination :label="$t('customers.title')" :hide-search="hideSearchField">
|
||||
<CustomersTable :objects="list" />
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
+47
-35
@@ -1,51 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, provide, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue";
|
||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
usePaginatedList,
|
||||
PaginatedListKey
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
import { provide, computed } from "vue";
|
||||
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||
const { t } = useI18n();
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
setEndpoint("/departments", false);
|
||||
setOrder('order_priority', 'asc');
|
||||
setOrder("order_priority", "asc");
|
||||
|
||||
const archivedFilter = ref("0");
|
||||
|
||||
const sortedList = computed(() => {
|
||||
return [...list.value].sort((a, b) => (a.order_priority || 0) - (b.order_priority || 0));
|
||||
});
|
||||
|
||||
// Hide the search field, if the hideSearch prop is set
|
||||
const handleArchivedFilterChange = () => {
|
||||
setFilter("archived", archivedFilter.value);
|
||||
};
|
||||
|
||||
setFilter("archived", archivedFilter.value, false);
|
||||
|
||||
if (props.hideSearch) {
|
||||
setHideSearchField(true);
|
||||
}
|
||||
@@ -53,17 +43,39 @@ if (props.hideSearch) {
|
||||
if (props.autoLoad) {
|
||||
loadList();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_department')" v-if="!hideSearchField"/>
|
||||
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage"/>
|
||||
<DepartmentsTable :objects="sortedList" />
|
||||
<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('common.departments')"
|
||||
:hide-search="hideSearchField"
|
||||
>
|
||||
<template #paginationDisplayFiltersElement>
|
||||
<div class="column is-narrow my-3 department-status-filter">
|
||||
<label class="label is-small" for="department-archive-filter">{{
|
||||
t("common.status")
|
||||
}}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select
|
||||
id="department-archive-filter"
|
||||
v-model="archivedFilter"
|
||||
data-testid="superuser-departments-archive-filter"
|
||||
@change="handleArchivedFilterChange"
|
||||
>
|
||||
<option value="0">{{ t("objects.departments.filters.active") }}</option>
|
||||
<option value="1">{{ t("objects.departments.filters.archived") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<DepartmentsTable :objects="sortedList" />
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
.department-status-filter {
|
||||
min-width: 14rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
+58
-13
@@ -74,11 +74,24 @@ const props = defineProps({
|
||||
showDraftAssignmentActions: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
invoicePeriodFlags: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
import { ref } from "vue";
|
||||
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
|
||||
import { computed, ref, provide } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
usePaginatedList,
|
||||
PaginatedListKey,
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const paginatedList = usePaginatedList();
|
||||
provide(PaginatedListKey, paginatedList);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
list,
|
||||
loadList,
|
||||
@@ -91,7 +104,7 @@ import {
|
||||
setAdditionalQueryParameters,
|
||||
setOrder,
|
||||
setHideSearchField,
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
} = paginatedList;
|
||||
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
|
||||
@@ -101,6 +114,11 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
const areFiltersVisible = ref(!props.hideFilter);
|
||||
const filterToggleLabel = computed(() => (
|
||||
areFiltersVisible.value ? t("pagination.hide_filters") : t("pagination.show_filters")
|
||||
));
|
||||
const filterToggleIcon = computed(() => (areFiltersVisible.value ? "fa-chevron-up" : "fa-filter"));
|
||||
setHideSearchField(props.hideSearch);
|
||||
setEndpoint("/orders", false);
|
||||
// If the customer filter is set, filter the orders by the customer number
|
||||
@@ -177,7 +195,28 @@ const doesEndpointMatch = (matcher) => {
|
||||
|
||||
<template>
|
||||
<template v-if="doesEndpointMatch('/orders')">
|
||||
<TableLabeledPagination :label="SessionUser.objects.orders.meta.labels.multiple">
|
||||
<TableLabeledPagination
|
||||
:label="SessionUser.objects.orders.meta.labels.multiple"
|
||||
:show-filters="areFiltersVisible"
|
||||
:hide-search="props.hideSearch"
|
||||
:hide-pagination="props.hidePagination"
|
||||
>
|
||||
<template #afterSearch v-if="props.hideFilter">
|
||||
<div class="invoice-orders-filter-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text is-small"
|
||||
data-testid="invoice-orders-toggle-filters"
|
||||
:aria-expanded="areFiltersVisible ? 'true' : 'false'"
|
||||
@click="areFiltersVisible = !areFiltersVisible"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas" :class="filterToggleIcon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ filterToggleLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #paginationDisplayFiltersElement>
|
||||
<PaginationDisplayFilters
|
||||
:departmentColumn="'department_id'"
|
||||
@@ -202,7 +241,7 @@ const doesEndpointMatch = (matcher) => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setFilter('booked_invoice_id', $event.target.value, true);">
|
||||
<option value="*" :selected="!props.applyDefaultFilters">{{ t('pagination.all') }}</option>
|
||||
<option value="*" :selected="!props.applyDefaultFilters">{{ t('common.all') }}</option>
|
||||
<option value="not null">{{ t('pagination.booked') }}</option>
|
||||
<option value="is_null" :selected="props.applyDefaultFilters">{{ t('pagination.not_booked') }}</option>
|
||||
</select>
|
||||
@@ -215,9 +254,9 @@ const doesEndpointMatch = (matcher) => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setFilter('error_message', $event.target.value, true);">
|
||||
<option value="*" selected>{{ t('pagination.all') }}</option>
|
||||
<option value="not null">{{ t('pagination.yes') }}</option>
|
||||
<option value="is_null">{{ t('pagination.no') }}</option>
|
||||
<option value="*" selected>{{ t('common.all') }}</option>
|
||||
<option value="not null">{{ t('common.yes') }}</option>
|
||||
<option value="is_null">{{ t('common.no') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +267,7 @@ const doesEndpointMatch = (matcher) => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setFilter('processor', $event.target.value, true);">
|
||||
<option value="*" selected>{{ t('pagination.all') }}</option>
|
||||
<option value="*" selected>{{ t('common.all') }}</option>
|
||||
<option value="1">E-conomic</option>
|
||||
<option value="2">Stripe</option>
|
||||
<option value="3">{{ t('pagination.other_no_tracking') }}</option>
|
||||
@@ -243,7 +282,7 @@ const doesEndpointMatch = (matcher) => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setFilter('completed_at', $event.target.value, true);">
|
||||
<option value="*" selected>{{ t('pagination.all') }}</option>
|
||||
<option value="*" selected>{{ t('common.all') }}</option>
|
||||
<option value="not null">{{ t('pagination.completed') }}</option>
|
||||
<option :value="'is_null'">{{ t('pagination.not_completed') }}</option>
|
||||
</select>
|
||||
@@ -256,7 +295,7 @@ const doesEndpointMatch = (matcher) => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setFilter('customer_id-has_attribute', $event.target.value, true);">
|
||||
<option value="*" selected>{{ t('pagination.all') }}</option>
|
||||
<option value="*" selected>{{ t('common.all') }}</option>
|
||||
<option value="invoiceAllOrdersIndividually">{{ t('pagination.invoice_per_order') }}</option>
|
||||
<option value="!invoiceAllOrdersIndividually">{{ t('pagination.invoice_per_month') }}</option>
|
||||
</select>
|
||||
@@ -269,7 +308,7 @@ const doesEndpointMatch = (matcher) => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setFilter('customer_id-has_key-OtherSpecialArrangement', $event.target.value, true);">
|
||||
<option value="*" :selected="!props.applyDefaultFilters">{{ t('pagination.all') }}</option>
|
||||
<option value="*" :selected="!props.applyDefaultFilters">{{ t('common.all') }}</option>
|
||||
<option value="OtherSpecialArrangement">{{ t('pagination.has_special_agreement') }}</option>
|
||||
<option value="!OtherSpecialArrangement" :selected="props.applyDefaultFilters">{{ t('pagination.no_special_agreement') }}</option>
|
||||
</select>
|
||||
@@ -282,7 +321,7 @@ const doesEndpointMatch = (matcher) => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setFilter('customer_id-has_key-OtherVaskeabonnement', $event.target.value, true);">
|
||||
<option value="*" :selected="!props.applyDefaultFilters">{{ t('pagination.all') }}</option>
|
||||
<option value="*" :selected="!props.applyDefaultFilters">{{ t('common.all') }}</option>
|
||||
<option value="OtherVaskeabonnement">{{ t('pagination.has_wash_subscription') }}</option>
|
||||
<option value="!OtherVaskeabonnement" :selected="props.applyDefaultFilters">{{ t('pagination.no_wash_subscription') }}</option>
|
||||
</select>
|
||||
@@ -334,6 +373,9 @@ const doesEndpointMatch = (matcher) => {
|
||||
v-bind:showOnlyWithIds="props.showOnlyWithIds"
|
||||
v-bind:autoExpandAll="props.autoExpandAll"
|
||||
v-bind:excludedOrderIds="props.excludedOrderIds"
|
||||
:invoice-period-flags="props.invoicePeriodFlags"
|
||||
@flagStatusChanged="(flag) => emit('flagStatusChanged', flag)"
|
||||
@flagCreated="(flag) => emit('flagCreated', flag)"
|
||||
/>
|
||||
</template>
|
||||
</TableLabeledPagination>
|
||||
@@ -341,5 +383,8 @@ const doesEndpointMatch = (matcher) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.invoice-orders-filter-toggle {
|
||||
margin-top: -0.35rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ if (props.autoLoad) {
|
||||
</div>
|
||||
<!-- Filter by status -->
|
||||
<div class="column is-narrow my-3">
|
||||
<label class="label">{{ t('pagination.status') }}</label>
|
||||
<label class="label">{{ t('common.status') }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setShowClosedInvoices($event.target.value === 'true')" v-model="showClosedInvoices">
|
||||
|
||||
+20
-3
@@ -7,7 +7,24 @@ import PaginationDisplay from "@/components/displays/pagination/PaginationDispla
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const props = defineProps(["hideSearch", "autoLoad"]);
|
||||
const props = defineProps({
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autoLoad: {
|
||||
type: [Boolean, String],
|
||||
default: false,
|
||||
},
|
||||
endpoint: {
|
||||
type: String,
|
||||
default: "/subusers",
|
||||
},
|
||||
showCustomer: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
const paginatedList = usePaginatedList();
|
||||
@@ -30,7 +47,7 @@ const {
|
||||
setAdditionalQueryParameters,
|
||||
} = paginatedList;
|
||||
|
||||
setEndpoint("/subusers", false);
|
||||
setEndpoint(props.endpoint, false);
|
||||
setAdditionalQueryParameters({ include_non_enabled: true });
|
||||
setOrder("created_at", "desc");
|
||||
|
||||
@@ -82,7 +99,7 @@ if (props.autoLoad) {
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
|
||||
<SubusersTable :objects="list" />
|
||||
<SubusersTable :objects="list" :show-customer="showCustomer" />
|
||||
|
||||
<PaginationNavigation
|
||||
:currentPage="metaCurrentPage"
|
||||
|
||||
@@ -1,45 +1,21 @@
|
||||
<script setup>
|
||||
import UsersTable from "@/components/displays/superuser/tables/usersTable.vue";
|
||||
|
||||
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
usePaginatedList,
|
||||
PaginatedListKey
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
import { provide } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||
import UsersTable from "@/components/displays/superuser/tables/usersTable.vue";
|
||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||
|
||||
const paginatedList = usePaginatedList();
|
||||
provide(PaginatedListKey, paginatedList);
|
||||
const {
|
||||
isLoaded,
|
||||
isLoading,
|
||||
list,
|
||||
loadList,
|
||||
metaCurrentPage,
|
||||
metaItemsPerPage,
|
||||
metaTotalItems,
|
||||
setEndpoint,
|
||||
setMetaItemsPerPage,
|
||||
setPage,
|
||||
metaSearch,
|
||||
search,
|
||||
setFilter,
|
||||
setOrder,
|
||||
hideSearchField,
|
||||
setHideSearchField,
|
||||
} = paginatedList;
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
const { list, loadList, setEndpoint, setFilter, setOrder, hideSearchField, setHideSearchField } = paginatedList;
|
||||
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
setEndpoint("/users", false);
|
||||
setFilter("customer_number", 0, false);
|
||||
setOrder("created_at", "desc");
|
||||
|
||||
// Hide the search field, if the hideSearch prop is set
|
||||
if (props.hideSearch) {
|
||||
@@ -49,32 +25,35 @@ if (props.hideSearch) {
|
||||
if (props.autoLoad) {
|
||||
loadList();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_user')" v-if="!hideSearchField"/>
|
||||
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
||||
<template #paginationColumns>
|
||||
<TableLabeledPagination
|
||||
:label="t('superuser.pages.employees.title')"
|
||||
:hide-search="hideSearchField"
|
||||
:search-placeholder="t('global.search_user')"
|
||||
>
|
||||
<template #paginationDisplayFiltersElement>
|
||||
<!-- Sort by created_at -->
|
||||
<div class="column is-narrow my-3">
|
||||
<label class="label is-small">{{ t('pagination.order_direction') }}</label>
|
||||
<label class="label is-small">{{ t("pagination.order_direction") }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setOrder('created_at', $event.target.value); loadList();">
|
||||
<option value="asc">{{ t('pagination.ascending') }}</option>
|
||||
<option value="desc" selected>{{ t('pagination.descending') }}</option>
|
||||
<select
|
||||
@change="
|
||||
setOrder('created_at', $event.target.value);
|
||||
loadList();
|
||||
"
|
||||
>
|
||||
<option value="asc">{{ t("pagination.ascending") }}</option>
|
||||
<option value="desc" selected>{{ t("pagination.descending") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
<UsersTable :objects="list" />
|
||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
||||
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
|
||||
<UsersTable :objects="list" />
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user