Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
85a6506bfd | ||
|
|
7a489e371f | ||
|
|
17b973d77c |
@@ -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
|
||||
+10
-11
@@ -61,17 +61,18 @@ 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]
|
||||
env:
|
||||
PLAYWRIGHT_PR_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
PLAYWRIGHT_PR_HEAD: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
@@ -85,17 +86,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="$PLAYWRIGHT_PR_BASE" --head="$PLAYWRIGHT_PR_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,18 @@
|
||||
FROM node:24-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.coolify-frontend.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
+1
-1
@@ -16,6 +16,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,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /release-entry.json {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /internal/frontend/release-entry.json {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files /release-entry.json =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ^~ /internal/frontend/assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
rewrite ^/internal/frontend/(.*)$ /$1 break;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ^~ /internal/frontend/ {
|
||||
rewrite ^/internal/frontend/(.*)$ /$1 break;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
+774
-3
@@ -3375,7 +3375,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 +3386,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 +4649,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 +4709,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 +4777,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 +5206,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 +6393,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 +6480,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 +8822,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 +8842,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 +13793,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 +15042,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 +15409,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 +15517,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 +15870,8 @@ components:
|
||||
type: integer
|
||||
visible:
|
||||
type: boolean
|
||||
archived:
|
||||
type: boolean
|
||||
dimension:
|
||||
type: integer
|
||||
branding:
|
||||
@@ -15168,6 +15927,8 @@ components:
|
||||
type: integer
|
||||
machine_available:
|
||||
type: boolean
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
|
||||
DepartmentCreate:
|
||||
type: object
|
||||
@@ -15183,6 +15944,8 @@ components:
|
||||
type: integer
|
||||
visible:
|
||||
type: boolean
|
||||
archived:
|
||||
type: boolean
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
@@ -15203,6 +15966,8 @@ components:
|
||||
type: string
|
||||
visible:
|
||||
type: boolean
|
||||
archived:
|
||||
type: boolean
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
@@ -15236,6 +16001,9 @@ components:
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
status:
|
||||
type: string
|
||||
created_at:
|
||||
@@ -15272,6 +16040,9 @@ components:
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
DepartmentLaneUpdate:
|
||||
type: object
|
||||
@@ -15301,6 +16072,8 @@ components:
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
selfserve_enabled:
|
||||
type: boolean
|
||||
|
||||
DepartmentGate:
|
||||
type: object
|
||||
@@ -17254,5 +18027,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",
|
||||
|
||||
+18
-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",
|
||||
@@ -30,7 +44,7 @@
|
||||
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
|
||||
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
|
||||
"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 +81,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",
|
||||
|
||||
@@ -342,7 +342,9 @@ function resolveExpectedContentType(specifier) {
|
||||
}
|
||||
|
||||
if (cleanSpecifier.endsWith(".json")) {
|
||||
return "json";
|
||||
// Vite serves imported JSON modules as JavaScript ("?import" transform),
|
||||
// so warming should verify a JavaScript content type.
|
||||
return "javascript";
|
||||
}
|
||||
|
||||
return "javascript";
|
||||
@@ -384,12 +386,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
RewriteRule ^index\.html$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
</IfModule>
|
||||
@@ -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,83 @@
|
||||
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: "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,
|
||||
},
|
||||
];
|
||||
@@ -18,6 +18,7 @@ const ownedFilesByRole = {
|
||||
"i18n.smoke.spec.ts",
|
||||
"i18n.views.spec.ts",
|
||||
"navigation.smoke.spec.js",
|
||||
"qr-new-customer-layout.spec.ts",
|
||||
"self-serve-wash.spec.js",
|
||||
"user-orders.spec.ts",
|
||||
"userBookings.spec.ts",
|
||||
@@ -61,16 +62,27 @@ const ownedFilesByRole = {
|
||||
"pos.visual.spec.js",
|
||||
],
|
||||
superuser: [
|
||||
"edge-gateways.fleet-outline.spec.js",
|
||||
"edge-gateways.routes.spec.js",
|
||||
"edge-gateways.smoke.spec.js",
|
||||
"edge-gateways.visual.spec.js",
|
||||
"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",
|
||||
"self-serve-sessions.spec.js",
|
||||
"self-serve-studio-audit-navigation.spec.js",
|
||||
"self-serve-studio-flow.spec.js",
|
||||
"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-vehicles.smoke.spec.js",
|
||||
"workfeed-config.smoke.spec.js",
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
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",
|
||||
},
|
||||
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();
|
||||
return runPlaywright({
|
||||
label: `core ${prGrep} gate`,
|
||||
artifactSuffix: "core",
|
||||
commandArgs: ["--grep", prGrep, ...buildProjectArgs(projects)],
|
||||
});
|
||||
}
|
||||
|
||||
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(", ")}`
|
||||
);
|
||||
return runPlaywright({
|
||||
label: `fallback ${smokeGrep} gate`,
|
||||
artifactSuffix: "smoke-fallback",
|
||||
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, ...buildProjectArgs(projects)],
|
||||
});
|
||||
}
|
||||
|
||||
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()) {
|
||||
const code = await runPlaywright({
|
||||
label: `changed-area specs ${index + 1}/${groups.length}`,
|
||||
artifactSuffix: `changed-${index + 1}`,
|
||||
commandArgs: [...group.specs, ...buildProjectArgs(group.projects)],
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
+47
-24
@@ -1,41 +1,52 @@
|
||||
<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 +60,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 +74,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 +96,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 +123,13 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
||||
<header></header>
|
||||
<main>
|
||||
<DefaultPageWrapper>
|
||||
<VersionCheck/>
|
||||
<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;
|
||||
|
||||
@@ -20,7 +20,7 @@ const redirect = (path) => {
|
||||
|
||||
<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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ref } from "vue";
|
||||
import CustomerModal from "@/components/displays/modals/CustomerModal.vue";
|
||||
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { API_URL } from "@/config.js";
|
||||
import { getReleaseRuntimeApiBaseUrl } from "@/services/releaseTimeline.js";
|
||||
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
|
||||
import {
|
||||
buildCustomerAttributeTargetPayload,
|
||||
@@ -23,10 +23,13 @@ const { t } = useI18n();
|
||||
const slots = useSlots();
|
||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
||||
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
|
||||
const emit = defineEmits(["deleted"]);
|
||||
const emit = defineEmits(["deleted", "flagCreated"]);
|
||||
const emitDeleted = () => {
|
||||
emit("deleted");
|
||||
};
|
||||
const emitFlagCreated = (flag) => {
|
||||
emit("flagCreated", flag);
|
||||
};
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
@@ -86,6 +89,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowBookingCompletion: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
department_lane_id: {
|
||||
type: Number,
|
||||
default: null,
|
||||
@@ -94,6 +101,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
flagTarget: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const customerModalVisible = ref(false);
|
||||
@@ -106,9 +117,12 @@ const customerRuleAttributesLoading = ref(false);
|
||||
const dropdownRoot = ref(null);
|
||||
const dropdownContent = ref(null);
|
||||
const isDropdownOpen = ref(false);
|
||||
const dropdownInstanceId = Math.random().toString(36).substring(2, 15);
|
||||
const shouldOpenDropdownUp = ref(false);
|
||||
const dropdownMaxHeight = ref(null);
|
||||
const isDesktopFlyoutLayout = ref(false);
|
||||
const isFixedPosition = ref(false);
|
||||
const fixedPositionStyles = ref({});
|
||||
const activeDesktopFlyoutSectionKey = ref(null);
|
||||
const activeAttachmentId = ref(null);
|
||||
const previewLoadingAttachmentId = ref(null);
|
||||
@@ -117,6 +131,7 @@ const previewSourcesById = ref({});
|
||||
const previewRequestsInFlight = new Set();
|
||||
const generatedObjectUrls = new Set();
|
||||
let previewStateGeneration = 0;
|
||||
let contentResizeObserver = null;
|
||||
const desktopFlyoutMinViewportWidth = 1400;
|
||||
const desktopFlyoutRootPanelWidthRem = 15;
|
||||
const desktopFlyoutSubmenuWidthRem = 17;
|
||||
@@ -126,8 +141,27 @@ const closeDropdown = () => {
|
||||
isDropdownOpen.value = false;
|
||||
};
|
||||
|
||||
const openDropdown = () => {
|
||||
if (!isDropdownOpen.value) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("action-settings-wheel:opened", {
|
||||
detail: {
|
||||
id: dropdownInstanceId,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
isDropdownOpen.value = true;
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isDropdownOpen.value = !isDropdownOpen.value;
|
||||
if (isDropdownOpen.value) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
openDropdown();
|
||||
};
|
||||
|
||||
const onActionSelected = () => {
|
||||
@@ -141,6 +175,7 @@ const getTriggerButtonClass = computed(() => ({
|
||||
"is-small": true,
|
||||
"is-dark": !isTextTriggerButton.value,
|
||||
"action-settings-wheel-trigger": true,
|
||||
"action-settings-wheel-trigger--active": isDropdownOpen.value,
|
||||
"action-settings-wheel-trigger--text": isTextTriggerButton.value,
|
||||
"action-settings-wheel-trigger--icon-only": isIconOnlyTriggerButton.value,
|
||||
}));
|
||||
@@ -151,7 +186,30 @@ const dropdownClass = computed(() => ({
|
||||
const dropdownContentClass = computed(() => ({
|
||||
"action-settings-wheel-dropdown-content--desktop-flyout": isDesktopFlyoutLayout.value,
|
||||
}));
|
||||
const dropdownMenuStyle = computed(() => {
|
||||
if (!isFixedPosition.value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
position: "fixed",
|
||||
zIndex: 10000,
|
||||
left: "auto",
|
||||
bottom: "auto",
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
...fixedPositionStyles.value,
|
||||
};
|
||||
});
|
||||
const dropdownContentStyle = computed(() => {
|
||||
if (isFixedPosition.value) {
|
||||
return {
|
||||
maxHeight: dropdownMaxHeight.value ? `${dropdownMaxHeight.value}px` : "95vh",
|
||||
overflowY: "auto",
|
||||
overscrollBehavior: "contain",
|
||||
};
|
||||
}
|
||||
|
||||
if (!dropdownMaxHeight.value) {
|
||||
return {};
|
||||
}
|
||||
@@ -166,6 +224,8 @@ const dropdownContentStyle = computed(() => {
|
||||
const resetDropdownLayout = () => {
|
||||
shouldOpenDropdownUp.value = false;
|
||||
dropdownMaxHeight.value = null;
|
||||
isFixedPosition.value = false;
|
||||
fixedPositionStyles.value = {};
|
||||
};
|
||||
|
||||
const isVisibleFixedInsetElement = (element) => {
|
||||
@@ -234,6 +294,7 @@ const updateDropdownLayout = async () => {
|
||||
const triggerElement = dropdownRoot.value.querySelector(".dropdown-trigger");
|
||||
const triggerRect = (triggerElement ?? dropdownRoot.value).getBoundingClientRect();
|
||||
syncDesktopFlyoutState(triggerRect);
|
||||
syncDesktopFlyoutPosition();
|
||||
|
||||
await nextTick();
|
||||
|
||||
@@ -254,6 +315,8 @@ const updateDropdownLayout = async () => {
|
||||
shouldOpenDropdownUp.value = openUpward;
|
||||
dropdownMaxHeight.value = nextMaxHeight;
|
||||
|
||||
syncDesktopFlyoutPosition();
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (!dropdownContent.value) {
|
||||
@@ -270,6 +333,8 @@ const updateDropdownLayout = async () => {
|
||||
|
||||
const boundedHeight = Math.floor(renderedMenuRect.height - overflowAbove - overflowBelow);
|
||||
dropdownMaxHeight.value = boundedHeight > 0 ? boundedHeight : 1;
|
||||
|
||||
syncDesktopFlyoutPosition();
|
||||
};
|
||||
|
||||
const onDocumentClick = (event) => {
|
||||
@@ -288,6 +353,12 @@ const onDocumentKeydown = (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onActionSettingsWheelOpened = (event) => {
|
||||
if (event?.detail?.id !== dropdownInstanceId) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const onViewportChange = () => {
|
||||
if (!isDropdownOpen.value) {
|
||||
return;
|
||||
@@ -418,7 +489,7 @@ const showSetCustomerPassword = (userId) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.error"),
|
||||
title: t("common.error"),
|
||||
text: t("admin.pos.settings_wheel.error_changing_password"),
|
||||
icon: "error",
|
||||
});
|
||||
@@ -447,6 +518,197 @@ const redirectDepartmentOrderPage = async (orderId, newTab = false) => {
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeInvoicePeriodFlagTarget = (target) => {
|
||||
if (!target || typeof target !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetType = String(target.target_type || target.targetType || "").trim();
|
||||
const targetId = Number.parseInt(String(target.target_id || target.targetId || ""), 10);
|
||||
if (!targetType || !Number.isInteger(targetId) || targetId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
field: target.field || null,
|
||||
};
|
||||
};
|
||||
|
||||
const getInvoicePeriodFlagTargetLabel = (target) => {
|
||||
if (!target || typeof target !== "object") {
|
||||
return t("admin.pos.settings_wheel.add_flag_target");
|
||||
}
|
||||
|
||||
if (target.target_type === "collected_order_invoice") {
|
||||
return t("admin.pos.settings_wheel.add_flag_invoice_collection");
|
||||
}
|
||||
|
||||
if (target.target_type === "order") {
|
||||
return t("admin.pos.settings_wheel.add_flag_order");
|
||||
}
|
||||
|
||||
if (target.target_type === "order_field") {
|
||||
const orderFieldLabels = {
|
||||
customer: t("admin.pos.settings_wheel.add_flag_order_customer"),
|
||||
reference: t("admin.pos.settings_wheel.add_flag_order_reference"),
|
||||
po: t("admin.pos.settings_wheel.add_flag_order_po"),
|
||||
notes: t("admin.pos.settings_wheel.add_flag_order_notes"),
|
||||
};
|
||||
|
||||
return orderFieldLabels[target.field] ?? t("admin.pos.settings_wheel.add_flag_order");
|
||||
}
|
||||
|
||||
if (target.target_type === "order_item") {
|
||||
return t("admin.pos.settings_wheel.add_flag_order_item");
|
||||
}
|
||||
|
||||
if (target.target_type === "order_item_field") {
|
||||
const orderItemFieldLabels = {
|
||||
notes: t("admin.pos.settings_wheel.add_flag_order_item_notes"),
|
||||
quantity: t("admin.pos.settings_wheel.add_flag_order_item_quantity"),
|
||||
reference: t("admin.pos.settings_wheel.add_flag_order_item_reference"),
|
||||
price: t("admin.pos.settings_wheel.add_flag_order_item_price"),
|
||||
};
|
||||
|
||||
return orderItemFieldLabels[target.field] ?? t("admin.pos.settings_wheel.add_flag_order_item");
|
||||
}
|
||||
|
||||
if (target.target_type === "customer") {
|
||||
return t("admin.pos.settings_wheel.add_flag_customer");
|
||||
}
|
||||
|
||||
return t("admin.pos.settings_wheel.add_flag_target");
|
||||
};
|
||||
|
||||
const defaultInvoicePeriodFlagTargets = computed(() => {
|
||||
const targets = [];
|
||||
const explicitTarget = normalizeInvoicePeriodFlagTarget(props.flagTarget);
|
||||
|
||||
if (explicitTarget && explicitTarget.target_type !== "order_item") {
|
||||
targets.push({
|
||||
key: "custom",
|
||||
label: getInvoicePeriodFlagTargetLabel(explicitTarget),
|
||||
target: explicitTarget,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.invoice_collection_id) {
|
||||
targets.push({
|
||||
key: "collected-order-invoice",
|
||||
label: t("admin.pos.settings_wheel.add_flag_invoice_collection"),
|
||||
target: {
|
||||
target_type: "collected_order_invoice",
|
||||
target_id: props.invoice_collection_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (props.order_id) {
|
||||
targets.push({
|
||||
key: "order",
|
||||
label: t("admin.pos.settings_wheel.add_flag_order"),
|
||||
target: {
|
||||
target_type: "order",
|
||||
target_id: props.order_id,
|
||||
},
|
||||
});
|
||||
[
|
||||
["customer", t("admin.pos.settings_wheel.add_flag_order_customer")],
|
||||
["reference", t("admin.pos.settings_wheel.add_flag_order_reference")],
|
||||
["po", t("admin.pos.settings_wheel.add_flag_order_po")],
|
||||
["notes", t("admin.pos.settings_wheel.add_flag_order_notes")],
|
||||
].forEach(([field, label]) => {
|
||||
targets.push({
|
||||
key: `order-${field}`,
|
||||
label,
|
||||
target: {
|
||||
target_type: "order_field",
|
||||
target_id: props.order_id,
|
||||
field,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (explicitTarget?.target_type === "order_item") {
|
||||
[
|
||||
[null, t("admin.pos.settings_wheel.add_flag_order_item")],
|
||||
["notes", t("admin.pos.settings_wheel.add_flag_order_item_notes")],
|
||||
["quantity", t("admin.pos.settings_wheel.add_flag_order_item_quantity")],
|
||||
["reference", t("admin.pos.settings_wheel.add_flag_order_item_reference")],
|
||||
["price", t("admin.pos.settings_wheel.add_flag_order_item_price")],
|
||||
].forEach(([field, label]) => {
|
||||
targets.push({
|
||||
key: `order-item-${field || "all"}`,
|
||||
label,
|
||||
target: {
|
||||
target_type: field ? "order_item_field" : "order_item",
|
||||
target_id: explicitTarget.target_id,
|
||||
field,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (props.customer_number && !targets.some((item) => item.target.target_type === "customer")) {
|
||||
targets.push({
|
||||
key: "customer",
|
||||
label: t("admin.pos.settings_wheel.add_flag_customer"),
|
||||
target: {
|
||||
target_type: "customer",
|
||||
target_id: props.customer_number,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
return targets.filter((item) => {
|
||||
const key = `${item.target.target_type}:${item.target.target_id}:${item.target.field || ""}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const canAddInvoicePeriodFlag = computed(
|
||||
() =>
|
||||
SessionUser.canAccessSuperUser() &&
|
||||
SessionUser.hasPermission("add_invoice_period_flag") &&
|
||||
defaultInvoicePeriodFlagTargets.value.length > 0
|
||||
);
|
||||
|
||||
const showCreateInvoicePeriodFlagForm = async (target) => {
|
||||
const result = await Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.add_flag"),
|
||||
input: "textarea",
|
||||
inputPlaceholder: t("admin.pos.settings_wheel.add_flag_reason_placeholder"),
|
||||
inputValidator: (value) => {
|
||||
if (!value || String(value).trim() === "") {
|
||||
return t("admin.pos.settings_wheel.add_flag_reason_required");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t("admin.pos.settings_wheel.add_flag"),
|
||||
cancelButtonText: t("common.cancel"),
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await SessionUser.request("/superuser/invoicing/period/flags", "POST", {
|
||||
...target,
|
||||
reason: String(result.value || "").trim(),
|
||||
});
|
||||
|
||||
emitFlagCreated(response?.data?.data ?? response?.data ?? response);
|
||||
};
|
||||
|
||||
const hasUser = computed(() => Boolean(props.user_id || props.customer_number || userIdFromCustomerNumber.value));
|
||||
const customerAttributeTarget = computed(() => {
|
||||
if (!hasUser.value) {
|
||||
@@ -527,7 +789,7 @@ const customerShortcutItems = computed(() => {
|
||||
{
|
||||
key: "customer-shortcut-vehicles",
|
||||
icon: "fas fa-car",
|
||||
label: t("admin.pos.settings_wheel.shortcut_vehicles"),
|
||||
label: t("common.vehicles"),
|
||||
clickAction: () => SessionUser.functions.redirectTo.superUser(`/users/${userId}/vehicles`, true),
|
||||
},
|
||||
];
|
||||
@@ -730,7 +992,7 @@ const shouldRetryPreviewRequestWithAuth = (downloadLink) => {
|
||||
|
||||
try {
|
||||
const previewUrl = new URL(downloadLink, window.location.origin);
|
||||
const apiOrigin = new URL(API_URL, window.location.origin).origin;
|
||||
const apiOrigin = new URL(getReleaseRuntimeApiBaseUrl(), window.location.origin).origin;
|
||||
return previewUrl.origin === apiOrigin || previewUrl.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -920,7 +1182,7 @@ const previewAttachment = async (attachment) => {
|
||||
} catch (error) {
|
||||
console.warn("Unable to preview attachment", attachment?.id, error);
|
||||
Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.error"),
|
||||
title: t("common.error"),
|
||||
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
|
||||
icon: "error",
|
||||
});
|
||||
@@ -933,7 +1195,7 @@ const downloadAttachment = async (attachment) => {
|
||||
downloadAttachmentFile(downloadLink, attachment);
|
||||
} catch (error) {
|
||||
Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.error"),
|
||||
title: t("common.error"),
|
||||
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
|
||||
icon: "error",
|
||||
});
|
||||
@@ -1036,7 +1298,7 @@ const printAttachment = async (attachment) => {
|
||||
} catch (error) {
|
||||
console.warn("Unable to print attachment", attachment?.id, error);
|
||||
Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.error"),
|
||||
title: t("common.error"),
|
||||
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
|
||||
icon: "error",
|
||||
});
|
||||
@@ -1067,7 +1329,7 @@ const removeAttachment = async (attachment) => {
|
||||
} catch (error) {
|
||||
console.warn("Unable to delete attachment", attachment?.id, error);
|
||||
Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.error"),
|
||||
title: t("common.error"),
|
||||
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
|
||||
icon: "error",
|
||||
});
|
||||
@@ -1096,16 +1358,43 @@ watch(
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", onDocumentClick);
|
||||
document.addEventListener("keydown", onDocumentKeydown);
|
||||
window.addEventListener("action-settings-wheel:opened", onActionSettingsWheelOpened);
|
||||
window.addEventListener("resize", onViewportChange);
|
||||
document.addEventListener("scroll", onViewportChange, true);
|
||||
|
||||
contentResizeObserver = new ResizeObserver(() => {
|
||||
if (isDropdownOpen.value) {
|
||||
syncDesktopFlyoutPosition();
|
||||
}
|
||||
});
|
||||
|
||||
if (dropdownContent.value) {
|
||||
contentResizeObserver.observe(dropdownContent.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(dropdownContent, (newEl, oldEl) => {
|
||||
if (oldEl && contentResizeObserver) {
|
||||
contentResizeObserver.unobserve(oldEl);
|
||||
}
|
||||
if (newEl && contentResizeObserver) {
|
||||
contentResizeObserver.observe(newEl);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", onDocumentClick);
|
||||
document.removeEventListener("keydown", onDocumentKeydown);
|
||||
window.removeEventListener("action-settings-wheel:opened", onActionSettingsWheelOpened);
|
||||
window.removeEventListener("resize", onViewportChange);
|
||||
document.removeEventListener("scroll", onViewportChange, true);
|
||||
clearAttachmentPreviewState();
|
||||
|
||||
if (contentResizeObserver) {
|
||||
contentResizeObserver.disconnect();
|
||||
contentResizeObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
const onShowImpersonationQRCode = (src, directLink) => {
|
||||
@@ -1117,7 +1406,7 @@ const onShowImpersonationQRCode = (src, directLink) => {
|
||||
showCancelButton: true,
|
||||
focusConfirm: false,
|
||||
confirmButtonText: t("admin.pos.settings_wheel.copy_link"),
|
||||
cancelButtonText: t("admin.pos.settings_wheel.close"),
|
||||
cancelButtonText: t("common.close"),
|
||||
preConfirm: () => {
|
||||
navigator.clipboard.writeText(directLink);
|
||||
Swal.showValidationMessage(t("admin.pos.settings_wheel.link_copied"));
|
||||
@@ -1172,7 +1461,7 @@ const isToggleMenuItem = (item) => item?.type === "toggle";
|
||||
const downloadOrderAttachment = (attachment) =>
|
||||
downloadAttachment(attachment).catch(() => {
|
||||
Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.error"),
|
||||
title: t("common.error"),
|
||||
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
|
||||
icon: "error",
|
||||
});
|
||||
@@ -1186,7 +1475,7 @@ const openDepartmentWorkspaceTab = (tabId) =>
|
||||
|
||||
const openPrimaryGatewayPage = async () => {
|
||||
if (!props.department_id) {
|
||||
SessionUser.functions.redirectTo.superUser("/configuration/edgegateway", true);
|
||||
SessionUser.functions.redirectTo.superUser("/selfserve/edge-agents", true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1199,7 +1488,7 @@ const openPrimaryGatewayPage = async () => {
|
||||
|
||||
if (primaryGatewayId) {
|
||||
SessionUser.functions.redirectTo.superUser(
|
||||
`/configuration/edgegateway/${encodeURIComponent(String(primaryGatewayId))}/overview`,
|
||||
`/selfserve/edge-agents/${encodeURIComponent(String(primaryGatewayId))}/overview`,
|
||||
true
|
||||
);
|
||||
return;
|
||||
@@ -1208,7 +1497,36 @@ const openPrimaryGatewayPage = async () => {
|
||||
console.warn("Unable to resolve primary gateway from department workspace", error);
|
||||
}
|
||||
|
||||
SessionUser.functions.redirectTo.superUser("/configuration/edgegateway", true);
|
||||
SessionUser.functions.redirectTo.superUser("/selfserve/edge-agents", true);
|
||||
};
|
||||
|
||||
const showCompleteOrderBookingConfirmation = async () => {
|
||||
const result = await Swal.fire({
|
||||
title: t("bookings.complete_booking"),
|
||||
text: t("pos.confirm_complete_order"),
|
||||
icon: "warning",
|
||||
showDenyButton: true,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t("admin.pos.settings_wheel.view_booking_new_tab"),
|
||||
denyButtonText: t("tables.bookings.complete_wash_without_certificate"),
|
||||
cancelButtonText: t("common.cancel"),
|
||||
reverseButtons: true,
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
SessionUser.functions.redirectTo.department(
|
||||
props.department_id,
|
||||
"modules/bookings/order/" + props.order_booking_id,
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.isDenied) {
|
||||
await SessionUser.objects.order_bookings.functions.complete(props.order_booking_id, null, () => {
|
||||
props.refreshFunction();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const flatBuiltInMenuSections = computed(() => {
|
||||
@@ -1216,15 +1534,12 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
|
||||
if (props.order_booking_id) {
|
||||
const bookingSection = buildMenuSection("booking", t("admin.pos.settings_wheel.booking"), [
|
||||
(SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) && !props.order_id
|
||||
props.allowBookingCompletion
|
||||
? buildMenuAction("booking-complete", {
|
||||
icon: "fas fa-check-circle",
|
||||
icon: "fas fa-check",
|
||||
label: t("admin.pos.settings_wheel.mark_as_completed"),
|
||||
template: "success",
|
||||
clickAction: () =>
|
||||
SessionUser.objects.order_bookings.functions.showCompleteConfirmationModal(props.order_booking_id, () => {
|
||||
props.refreshFunction();
|
||||
}),
|
||||
clickAction: showCompleteOrderBookingConfirmation,
|
||||
})
|
||||
: null,
|
||||
buildMenuAction("booking-view", {
|
||||
@@ -1528,7 +1843,7 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
? buildMenuAction("department-gateways-open-fleet", {
|
||||
icon: "fas fa-sitemap",
|
||||
label: t("admin.pos.settings_wheel.open_fleet_landing"),
|
||||
clickAction: () => SessionUser.functions.redirectTo.superUser("/configuration/edgegateway", true),
|
||||
clickAction: () => SessionUser.functions.redirectTo.superUser("/selfserve/edge-agents", true),
|
||||
})
|
||||
: null,
|
||||
SessionUser.canAccessSuperUser()
|
||||
@@ -1713,6 +2028,25 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (canAddInvoicePeriodFlag.value) {
|
||||
const flagSection = buildMenuSection(
|
||||
"invoice-period-flags",
|
||||
t("admin.pos.settings_wheel.flags_section"),
|
||||
defaultInvoicePeriodFlagTargets.value.map((item) =>
|
||||
buildMenuAction(`add-invoice-period-flag-${item.key}`, {
|
||||
icon: "fas fa-flag",
|
||||
label: item.label,
|
||||
template: "danger",
|
||||
clickAction: () => showCreateInvoicePeriodFlagForm(item.target),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (flagSection) {
|
||||
sections.push(flagSection);
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
});
|
||||
|
||||
@@ -1770,6 +2104,24 @@ const desktopFlyoutMenuSections = computed(() => {
|
||||
return mergedSections;
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
activeDesktopFlyoutSectionKey,
|
||||
activeAttachmentId,
|
||||
isDesktopFlyoutLayout,
|
||||
desktopFlyoutMenuSections,
|
||||
activeAttachmentPreviewSource,
|
||||
attachmentsFromOrder,
|
||||
],
|
||||
() => {
|
||||
if (isDropdownOpen.value) {
|
||||
void nextTick(() => {
|
||||
syncDesktopFlyoutPosition();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const activeDesktopFlyoutSection = computed(() => {
|
||||
return (
|
||||
desktopFlyoutMenuSections.value.find((section) => section.key === activeDesktopFlyoutSectionKey.value) ??
|
||||
@@ -1826,6 +2178,118 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
||||
activeAttachmentId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const isOverflowClippingStyle = (style) =>
|
||||
["overflow", "overflowX", "overflowY"].some((property) => {
|
||||
const value = style[property];
|
||||
return value && value !== "visible";
|
||||
});
|
||||
|
||||
const isDropdownClippedByAncestor = (dropdownRect, triggerRect) => {
|
||||
let el = dropdownRoot.value?.parentElement;
|
||||
|
||||
while (el && el !== document.body) {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (isOverflowClippingStyle(style)) {
|
||||
const ancestorRect = el.getBoundingClientRect();
|
||||
const wouldClipCurrentDropdown =
|
||||
dropdownRect.top < ancestorRect.top ||
|
||||
dropdownRect.right > ancestorRect.right ||
|
||||
dropdownRect.bottom > ancestorRect.bottom ||
|
||||
dropdownRect.left < ancestorRect.left;
|
||||
const wouldClipBelow = triggerRect.bottom + dropdownRect.height > ancestorRect.bottom;
|
||||
const wouldClipAbove = triggerRect.top - dropdownRect.height < ancestorRect.top;
|
||||
|
||||
if (wouldClipCurrentDropdown || (wouldClipBelow && wouldClipAbove)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Smart re-positioning of the flyout menu when either it's partially or fully hidden
|
||||
const syncDesktopFlyoutPosition = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const dropdownContentEl = dropdownContent.value;
|
||||
const triggerEl = dropdownRoot.value?.querySelector(".dropdown-trigger") ?? dropdownRoot.value;
|
||||
if (!dropdownContentEl || !triggerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = triggerEl.getBoundingClientRect();
|
||||
const contentHeight = dropdownContentEl.scrollHeight;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const contentRect = dropdownContentEl.getBoundingClientRect();
|
||||
|
||||
let needsFixed = isDropdownClippedByAncestor(contentRect, triggerRect);
|
||||
if (isDesktopFlyoutLayout.value) {
|
||||
let el = dropdownRoot.value.parentElement;
|
||||
while (el && el !== document.body && el !== null) {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.overflowY !== "visible") {
|
||||
if (el.clientHeight < contentHeight + 10) {
|
||||
needsFixed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFixedPosition.value !== needsFixed) {
|
||||
isFixedPosition.value = needsFixed;
|
||||
}
|
||||
|
||||
if (needsFixed) {
|
||||
if (dropdownContentEl.style.top !== "") {
|
||||
dropdownContentEl.style.top = "";
|
||||
}
|
||||
|
||||
const padding = 12;
|
||||
let top = triggerRect.bottom;
|
||||
|
||||
if (top + contentHeight > viewportHeight - padding) {
|
||||
top = Math.max(padding, triggerRect.top - contentHeight);
|
||||
}
|
||||
|
||||
const nextFixedStyles = {
|
||||
top: `${top}px`,
|
||||
right: `${viewportWidth - triggerRect.right}px`,
|
||||
width: "max-content",
|
||||
};
|
||||
|
||||
if (
|
||||
fixedPositionStyles.value.top !== nextFixedStyles.top ||
|
||||
fixedPositionStyles.value.right !== nextFixedStyles.right ||
|
||||
fixedPositionStyles.value.width !== nextFixedStyles.width
|
||||
) {
|
||||
fixedPositionStyles.value = nextFixedStyles;
|
||||
}
|
||||
} else {
|
||||
const contentRect = dropdownContentEl.getBoundingClientRect();
|
||||
const contentBottom = contentRect.top + contentRect.height;
|
||||
|
||||
if (contentBottom > viewportHeight) {
|
||||
const nextTop = `${triggerRect.top - contentRect.height}px`;
|
||||
if (dropdownContentEl.style.top !== nextTop) {
|
||||
dropdownContentEl.style.top = nextTop;
|
||||
}
|
||||
} else {
|
||||
if (dropdownContentEl.style.top !== "") {
|
||||
dropdownContentEl.style.top = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1877,6 +2341,7 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
||||
<button
|
||||
type="button"
|
||||
:class="getTriggerButtonClass"
|
||||
class="is-inverted"
|
||||
aria-haspopup="true"
|
||||
aria-controls="dropdown-menu"
|
||||
:aria-expanded="isDropdownOpen ? 'true' : 'false'"
|
||||
@@ -1888,7 +2353,7 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
||||
<span v-if="props.label.length > 0" class="ml-2">{{ props.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu" :style="dropdownMenuStyle">
|
||||
<div
|
||||
ref="dropdownContent"
|
||||
class="dropdown-content"
|
||||
@@ -2039,7 +2504,7 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
||||
<span class="action-settings-wheel-attachment-action__icon">
|
||||
<i class="fas fa-download" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ t("global.download") }}</span>
|
||||
<span>{{ t("common.download") }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2104,38 +2569,40 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot v-if="hasCustomActionsSlot" name="actions"></slot>
|
||||
<template v-for="section in flatBuiltInMenuSections" :key="section.key">
|
||||
<ActionSettingsWheelItemLabel :label="section.label" />
|
||||
<template v-for="item in section.items" :key="item.key">
|
||||
<ActionSettingsWheelToggleItem
|
||||
v-if="isToggleMenuItem(item)"
|
||||
:checked="item.value"
|
||||
:click-action="item.clickAction"
|
||||
:description="item.description"
|
||||
:disabled="item.disabled"
|
||||
:label="item.label"
|
||||
:test-id="item.testId"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
v-else
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:template="item.template"
|
||||
:click-action="item.clickAction"
|
||||
:disabled="item.disabled"
|
||||
/>
|
||||
<div data-testid="action-settings-wheel-sections">
|
||||
<slot v-if="hasCustomActionsSlot" name="actions"></slot>
|
||||
<template v-for="section in flatBuiltInMenuSections" :key="section.key">
|
||||
<ActionSettingsWheelItemLabel :label="section.label" :data-testid="`action-settings-wheel-section-${section.key}`" />
|
||||
<template v-for="item in section.items" :key="item.key">
|
||||
<ActionSettingsWheelToggleItem
|
||||
v-if="isToggleMenuItem(item)"
|
||||
:checked="item.value"
|
||||
:click-action="item.clickAction"
|
||||
:description="item.description"
|
||||
:disabled="item.disabled"
|
||||
:label="item.label"
|
||||
:test-id="item.testId"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
v-else
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:template="item.template"
|
||||
:click-action="item.clickAction"
|
||||
:disabled="item.disabled"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<ActionSettingsWheelItem
|
||||
v-for="item in standaloneMenuActions"
|
||||
:key="item.key"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:template="item.template"
|
||||
:click-action="item.clickAction"
|
||||
:disabled="item.disabled"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
v-for="item in standaloneMenuActions"
|
||||
:key="item.key"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:template="item.template"
|
||||
:click-action="item.clickAction"
|
||||
:disabled="item.disabled"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -2159,6 +2626,11 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.action-settings-wheel-trigger--active:not(.action-settings-wheel-trigger--text) {
|
||||
border-color: #25344d;
|
||||
box-shadow: 0 0 0 3px rgba(37, 52, 77, 0.28);
|
||||
}
|
||||
|
||||
.action-settings-wheel-trigger--text {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
@@ -2215,7 +2687,13 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
min-width: 33rem;
|
||||
z-index: 4003;
|
||||
}
|
||||
/* When the flyout is partly out of it's parent element, it can get cut off by overflow:hidden, so we need to allow it to overflow, but attempt to keep it visible within the viewport with smart positioning logic in the component */
|
||||
.action-settings-wheel-flyout {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
|
||||
.action-settings-wheel-flyout__submenu-stack {
|
||||
display: grid;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
@@ -1161,6 +1263,7 @@ const formatCashierName = (order) => {
|
||||
v-bind:reg_1="order.reg_1"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
@flag-created="emitFlagCreated"
|
||||
:data-testid="`pos-order-list-settings-${order.id}`"
|
||||
>
|
||||
<template v-slot:actions>
|
||||
@@ -1185,13 +1288,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 +1466,11 @@ const formatCashierName = (order) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderLineFlags(order)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div>
|
||||
@@ -1380,6 +1504,7 @@ const formatCashierName = (order) => {
|
||||
v-bind:reg_1="order.reg_1"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
@flag-created="emitFlagCreated"
|
||||
:displayActionsDirectly="true"
|
||||
>
|
||||
<template #actions></template>
|
||||
@@ -1392,7 +1517,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 +1586,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 +1637,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 +1648,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 +1774,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 +1785,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 +1883,11 @@ const formatCashierName = (order) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderLineFlags(order)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</template>
|
||||
<template #content> </template>
|
||||
<template #footer>
|
||||
@@ -1765,6 +1950,7 @@ const formatCashierName = (order) => {
|
||||
/>
|
||||
<!-- bottom progress bar -->
|
||||
<InvoicingBillingPeriodInvoiceProgressBar
|
||||
v-if="shouldShowLegacyInvoiceProgressBar"
|
||||
v-bind:max-progress="
|
||||
invoiceCollectionQueueSuccess.length +
|
||||
invoiceCollectionQueueFailed.length +
|
||||
@@ -1813,6 +1999,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}`,
|
||||
@@ -279,7 +297,7 @@ const bookingSelectionObjects = computed(() => {
|
||||
const duplicateDetailsObjects = computed(() => {
|
||||
return duplicateOrders.value.map((order) => ({
|
||||
id: Number(order.id),
|
||||
label: `${t("admin.pos.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
|
||||
label: `${t("common.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
|
||||
content: getDuplicateOrderContent(order),
|
||||
buttons: [
|
||||
{
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -326,7 +326,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>
|
||||
|
||||
+14
-15
@@ -20,27 +20,26 @@ import GenericButton from "@/components/viewport/page/templates/generic/graphics
|
||||
import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const onClickClearAll = () => {
|
||||
const onClickClearAll = async () => {
|
||||
// 1. Delete the order (If any)
|
||||
// Check localstorage for pos_order_id
|
||||
if (localStorage.getItem("pos_order_id")) {
|
||||
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
|
||||
}
|
||||
if (order_id.value) {
|
||||
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();
|
||||
});
|
||||
try {
|
||||
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
|
||||
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 +53,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:
|
||||
|
||||
+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>
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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,8 @@ 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 { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
|
||||
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||
|
||||
const { t: $t } = useI18n();
|
||||
|
||||
@@ -61,6 +62,7 @@ const {
|
||||
syncVehicleAnswer,
|
||||
clearVehicleAnswers,
|
||||
evaluateCondition,
|
||||
downloadAttachment,
|
||||
} = useSelfServeLogic();
|
||||
|
||||
const emptyCompletedTasks = computed(() => ({}));
|
||||
@@ -145,22 +147,15 @@ const parseNonNegativeInt = (value) => {
|
||||
};
|
||||
|
||||
const dynamicImageButtons = computed(() => {
|
||||
const buttonIds = new Set();
|
||||
const buttonIds = new Map();
|
||||
|
||||
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);
|
||||
}
|
||||
normalizeSelfServeTaskButtons(task?.buttons).forEach((button) => {
|
||||
buttonIds.set(`${typeof button}:${button}`, button);
|
||||
});
|
||||
});
|
||||
|
||||
return [...buttonIds];
|
||||
return [...buttonIds.values()];
|
||||
});
|
||||
|
||||
const dynamicImageThumbPosition = computed(() => {
|
||||
@@ -201,7 +196,7 @@ const dynamicImageUrl = computed(() => {
|
||||
params.set("thumb_position", String(dynamicImageThumbPosition.value));
|
||||
}
|
||||
|
||||
return `${API_URL}/department/lanes/dynamic-image?${params.toString()}`;
|
||||
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
|
||||
});
|
||||
|
||||
const displayedDynamicImageUrl = computed(() => (
|
||||
@@ -435,7 +430,7 @@ 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -8,6 +8,13 @@ const { t } = useI18n();
|
||||
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const props = defineProps({
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const canExport = computed(() => typeof paginatedList?.exportToExcel === 'function');
|
||||
const isExporting = computed(() => Boolean(paginatedList?.isExporting?.value));
|
||||
const isActionDropdownOpen = ref(false);
|
||||
@@ -65,7 +72,7 @@ onBeforeUnmount(() => {
|
||||
<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"
|
||||
|
||||
@@ -23,6 +23,18 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
showFilters: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hidePagination: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const isSmall = ref(window.innerWidth < 1024);
|
||||
</script>
|
||||
@@ -31,14 +43,26 @@ 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"
|
||||
>
|
||||
<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>
|
||||
@@ -54,7 +78,14 @@ 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -93,7 +93,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"
|
||||
|
||||
+66
-28
@@ -1,18 +1,19 @@
|
||||
<script setup>
|
||||
import { computed, provide, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue";
|
||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
import { 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,
|
||||
@@ -22,30 +23,27 @@ const {
|
||||
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 +51,57 @@ 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"/>
|
||||
<PaginationDisplayGeneralSearchReload v-if="!hideSearchField" />
|
||||
<PaginationDisplay
|
||||
:metaItemsPerPage="metaItemsPerPage"
|
||||
:loadFunction="loadList"
|
||||
:isLoading="isLoading"
|
||||
:setMetaItemsPerPage="setMetaItemsPerPage"
|
||||
>
|
||||
<template #paginationColumns>
|
||||
<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>
|
||||
</PaginationDisplay>
|
||||
<div v-if="hideSearchField" class="mb-3">
|
||||
<button class="button is-dark" :class="{ 'is-loading': isLoading }" :disabled="isLoading" @click="loadList">
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-sync-alt" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ t("pagination.reload") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<DepartmentsTable :objects="sortedList" />
|
||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
||||
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
|
||||
<PaginationNavigation
|
||||
:currentPage="metaCurrentPage"
|
||||
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
|
||||
:loadFunction="loadList"
|
||||
:setPage="setPage"
|
||||
:isLoading="isLoading"
|
||||
/>
|
||||
</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">
|
||||
|
||||
@@ -108,11 +108,11 @@ const showNewOrderBookingsPortal = () => {
|
||||
<template #paginationColumns>
|
||||
<!-- 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="setFilter('status', $event.target.value)" v-model="selectedStatus">
|
||||
<option value="*">{{ t('pagination.all') }}</option>
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option value="pending">{{ t('pagination.pending') }}</option>
|
||||
<option value="completed">{{ t('pagination.completed') }}</option>
|
||||
<option value="cancelled">{{ t('pagination.cancelled') }}</option>
|
||||
@@ -126,9 +126,9 @@ const showNewOrderBookingsPortal = () => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setFilter('pickup_bool', $event.target.value)">
|
||||
<option value="*">{{ t('pagination.all') }}</option>
|
||||
<option value="1">{{ t('pagination.yes') }}</option>
|
||||
<option value="0">{{ t('pagination.no') }}</option>
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option value="1">{{ t('common.yes') }}</option>
|
||||
<option value="0">{{ t('common.no') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +139,7 @@ const showNewOrderBookingsPortal = () => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="setFilter('department', $event.target.value)">
|
||||
<option value="*">{{ t('pagination.all') }}</option>
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option v-for="department in departments" :key="department.id" :value="department.id">{{ department.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -148,7 +148,7 @@ const showNewOrderBookingsPortal = () => {
|
||||
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
|
||||
<!-- Only show the bookings for today (Switch, if the route is /user) -->
|
||||
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
|
||||
<label class="label">{{ isSmall ? t('pagination.today') : t('pagination.show_only_today') }}</label>
|
||||
<label class="label">{{ isSmall ? t('common.today') : t('pagination.show_only_today') }}</label>
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? new Date().toISOString().split('T')[0] : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
|
||||
|
||||
@@ -116,11 +116,11 @@ onMounted(() => {
|
||||
<!-- Status filter -->
|
||||
<div class="column is-narrow">
|
||||
<div class="field mb-0">
|
||||
<label class="label is-small">{{ t('pagination.status') }}</label>
|
||||
<label class="label is-small">{{ t('common.status') }}</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select :value="orderIdFilter" @change="onOrderIdFilterChange">
|
||||
<option value="*">{{ t('pagination.all') }}</option>
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option value="is null">{{ t('pagination.not_completed') }}</option>
|
||||
<option value="not null">{{ t('pagination.completed') }}</option>
|
||||
</select>
|
||||
@@ -135,7 +135,7 @@ onMounted(() => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select :value="departmentFilter" @change="onDepartmentFilterChange">
|
||||
<option value="*">{{ t('pagination.all') }}</option>
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option v-for="department in departments" :key="department.id" :value="department.id">{{ department.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -149,7 +149,7 @@ onMounted(() => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select @change="showLegacyOrderBookingsPortal(); resetVersionToNew()" v-model="versionSelector">
|
||||
<option value="new">{{ t('pagination.new') }}</option>
|
||||
<option value="new">{{ t('common.new') }}</option>
|
||||
<option value="legacy">{{ t('pagination.old') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -163,8 +163,8 @@ onMounted(() => {
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
|
||||
<option value="*">{{ t('pagination.all') }}</option>
|
||||
<option :value="new Date().toISOString().split('T')[0]">{{ t('pagination.yes') }}</option>
|
||||
<option value="*">{{ t('common.all') }}</option>
|
||||
<option :value="new Date().toISOString().split('T')[0]">{{ t('common.yes') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,17 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="self-serve-completed-step">
|
||||
<h1 class="title has-text-centered">{{ $t("self_wash.wash_completed") }}</h1>
|
||||
<p class="has-text-centered">
|
||||
<div data-testid="self-serve-completed-step" class="has-text-left">
|
||||
<h1 class="title has-text-left">{{ $t("self_wash.wash_completed") }}</h1>
|
||||
<p class="has-text-left">
|
||||
{{ $t("self_wash.wash_completed_message") }}
|
||||
</p>
|
||||
<p v-if="completedDurationMs !== null" class="has-text-centered">
|
||||
<p v-if="completedDurationMs !== null" class="has-text-left">
|
||||
<strong>
|
||||
{{ $t("self_wash.total_time", { minutes: Math.floor(completedDurationMs / 60000), seconds: Math.floor((completedDurationMs % 60000) / 1000) }) }}
|
||||
</strong>
|
||||
</p>
|
||||
<p class="has-text-centered">
|
||||
<p class="has-text-left">
|
||||
{{ $t("self_wash.wash_completed_contact") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
import { computed } from "vue";
|
||||
import { BButton, BField } from "buefy";
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
steps: Array<any>;
|
||||
currentStep: number;
|
||||
}>();
|
||||
showActions?: boolean;
|
||||
}>(), {
|
||||
showActions: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:currentStep", value: number): void;
|
||||
@@ -67,7 +70,7 @@ const goNext = () => {
|
||||
</b-field>
|
||||
</section>
|
||||
|
||||
<div class="buttons is-centered mt-4 guided-instructions__actions">
|
||||
<div v-if="showActions" class="buttons is-centered mt-4 guided-instructions__actions">
|
||||
<b-button
|
||||
type="is-link is-light"
|
||||
icon-pack="fas"
|
||||
|
||||
@@ -4,8 +4,6 @@ import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
defineProps<{
|
||||
visibleQuestions: Array<{ id: number; question: string; description?: string }>;
|
||||
answers: Record<number, boolean | undefined>;
|
||||
loadingQuestionId?: number | null;
|
||||
loadingAnswerValue?: boolean | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -30,12 +28,8 @@ const emit = defineEmits<{
|
||||
<div class="card-footer-item">
|
||||
<button
|
||||
class="button is-fullwidth"
|
||||
:class="[
|
||||
answers[question.id] === true ? 'is-success' : 'is-light',
|
||||
loadingQuestionId === question.id && loadingAnswerValue === true ? 'is-loading' : '',
|
||||
]"
|
||||
:class="[answers[question.id] === true ? 'is-success' : 'is-light']"
|
||||
:data-testid="`self-serve-question-${question.id}-yes`"
|
||||
:disabled="loadingQuestionId === question.id"
|
||||
@click="emit('answer-question', question.id, true)"
|
||||
>
|
||||
{{ $t("common.yes") }}
|
||||
@@ -44,12 +38,8 @@ const emit = defineEmits<{
|
||||
<div class="card-footer-item">
|
||||
<button
|
||||
class="button is-fullwidth"
|
||||
:class="[
|
||||
answers[question.id] === false ? 'is-danger' : 'is-light',
|
||||
loadingQuestionId === question.id && loadingAnswerValue === false ? 'is-loading' : '',
|
||||
]"
|
||||
:class="[answers[question.id] === false ? 'is-danger is-light' : 'is-light']"
|
||||
:data-testid="`self-serve-question-${question.id}-no`"
|
||||
:disabled="loadingQuestionId === question.id"
|
||||
@click="emit('answer-question', question.id, false)"
|
||||
>
|
||||
{{ $t("common.no") }}
|
||||
|
||||
@@ -6,8 +6,6 @@ defineProps<{
|
||||
isLoading: boolean;
|
||||
visibleQuestions: Array<any>;
|
||||
answers: Record<number, boolean | undefined>;
|
||||
loadingQuestionId?: number | null;
|
||||
loadingAnswerValue?: boolean | null;
|
||||
editAnswers: boolean;
|
||||
showDebug: boolean;
|
||||
conditions: Array<any>;
|
||||
@@ -37,6 +35,15 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
|
||||
<p>{{ $t("self_wash.loading_data") }}</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="notification is-info is-light py-2 px-3 mb-4"
|
||||
data-testid="self-serve-questions-inline-loading"
|
||||
>
|
||||
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="is-flex is-justify-content-center is-align-items-center mb-4">
|
||||
<h1 class="title mb-0">{{ $t("self_wash.answer_questions") }}</h1>
|
||||
<b-button
|
||||
@@ -136,8 +143,6 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
|
||||
<SelfServeQuestionCards
|
||||
:visibleQuestions="visibleQuestions"
|
||||
:answers="answers"
|
||||
:loading-question-id="loadingQuestionId"
|
||||
:loading-answer-value="loadingAnswerValue"
|
||||
@answer-question="emitAnswerQuestion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle-task", taskId: number, value: boolean): void;
|
||||
(e: "download-attachment", taskId: number, attachmentId: number): void;
|
||||
(e: "download-attachment", taskId: number, attachmentId: number | undefined, attachment?: any): void;
|
||||
}>();
|
||||
|
||||
const isImageAttachment = (attachment: any) => (
|
||||
@@ -55,6 +55,23 @@ const getVisibleServices = (task: any) => {
|
||||
|
||||
return task.services.filter((service: any) => String(service || "").trim().toUpperCase() !== "MACHINE");
|
||||
};
|
||||
|
||||
const getProgramWheelSelection = (task: any) => {
|
||||
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
|
||||
if (rawValue === null || rawValue === undefined || rawValue === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(String(rawValue), 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const formatTaskTitle = (task: any) => {
|
||||
const title = String(task?.task || "");
|
||||
const programWheelSelection = getProgramWheelSelection(task);
|
||||
|
||||
return programWheelSelection === null ? title : `#${programWheelSelection} ${title}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -76,7 +93,7 @@ const getVisibleServices = (task: any) => {
|
||||
:alt="getPrimaryImageAttachment(task)?.content?.other || task.task"
|
||||
class="self-serve-task-image is-clickable"
|
||||
:data-testid="`self-serve-task-${task.id}-attachment-${getPrimaryImageAttachment(task)?.id}`"
|
||||
@click="emit('download-attachment', task.id, getPrimaryImageAttachment(task)?.id)"
|
||||
@click="emit('download-attachment', task.id, getPrimaryImageAttachment(task)?.id, getPrimaryImageAttachment(task))"
|
||||
/>
|
||||
<div class="self-serve-task-image-overlay">
|
||||
<div class="self-serve-task-image-overlay-content">
|
||||
@@ -93,7 +110,7 @@ const getVisibleServices = (task: any) => {
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="self-serve-task-image-overlay-text">
|
||||
<p class="self-serve-task-title"><strong>{{ task.task }}</strong></p>
|
||||
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,7 +126,7 @@ const getVisibleServices = (task: any) => {
|
||||
<a
|
||||
class="is-size-7"
|
||||
:data-testid="`self-serve-task-${task.id}-attachment-${attachment.id}`"
|
||||
@click="emit('download-attachment', task.id, attachment.id)"
|
||||
@click="emit('download-attachment', task.id, attachment.id, attachment)"
|
||||
>
|
||||
{{ attachment.content?.other || $t("self_wash.attached_file") }}
|
||||
</a>
|
||||
@@ -131,7 +148,7 @@ const getVisibleServices = (task: any) => {
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
<p><strong>{{ task.task }}</strong></p>
|
||||
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
|
||||
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
|
||||
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
|
||||
@@ -145,7 +162,7 @@ const getVisibleServices = (task: any) => {
|
||||
style="max-width: 100%; height: auto; border-radius: 4px; display: block;"
|
||||
class="mb-1 is-clickable"
|
||||
:data-testid="`self-serve-task-${task.id}-attachment-${attachment.id}`"
|
||||
@click="emit('download-attachment', task.id, attachment.id)"
|
||||
@click="emit('download-attachment', task.id, attachment.id, attachment)"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="is-flex is-align-items-center">
|
||||
@@ -153,7 +170,7 @@ const getVisibleServices = (task: any) => {
|
||||
<a
|
||||
class="is-size-7"
|
||||
:data-testid="`self-serve-task-${task.id}-attachment-${attachment.id}`"
|
||||
@click="emit('download-attachment', task.id, attachment.id)"
|
||||
@click="emit('download-attachment', task.id, attachment.id, attachment)"
|
||||
>
|
||||
{{ attachment.content?.other || $t("self_wash.attached_file") }}
|
||||
</a>
|
||||
|
||||
@@ -13,7 +13,7 @@ defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle-task", taskId: number, value: boolean): void;
|
||||
(e: "download-attachment", taskId: number, attachmentId: number): void;
|
||||
(e: "download-attachment", taskId: number, attachmentId: number | undefined, attachment?: any): void;
|
||||
(e: "clear-dynamic-image"): void;
|
||||
}>();
|
||||
|
||||
@@ -21,18 +21,27 @@ const emitToggleTask = (taskId: number, value: boolean) => {
|
||||
emit("toggle-task", taskId, value);
|
||||
};
|
||||
|
||||
const emitDownloadAttachment = (taskId: number, attachmentId: number) => {
|
||||
emit("download-attachment", taskId, attachmentId);
|
||||
const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined, attachment?: any) => {
|
||||
emit("download-attachment", taskId, attachmentId, attachment);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="self-serve-tasks-step">
|
||||
<div v-if="isLoading" class="has-text-centered p-6">
|
||||
<div v-if="isLoading && activeTasks.length === 0" class="has-text-centered p-6">
|
||||
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
|
||||
<p>{{ $t("self_wash.loading_data") }}</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="notification is-info is-light py-2 px-3 mb-4"
|
||||
data-testid="self-serve-tasks-inline-loading"
|
||||
>
|
||||
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="dynamicImageUrl" class="mb-4">
|
||||
<img
|
||||
:src="dynamicImageUrl"
|
||||
|
||||
@@ -61,7 +61,7 @@ const emitRegistration = (value: unknown) => {
|
||||
|
||||
<template>
|
||||
<div data-testid="self-serve-vehicle-step">
|
||||
<h1 class="title has-text-centered">{{ $t("self_wash.vehicle") }}</h1>
|
||||
<h1 class="title has-text-centered">{{ $t("common.vehicle") }}</h1>
|
||||
<b-field v-if="showCustomerNumberInput" :label="$t('self_wash.customer_number')">
|
||||
<b-input
|
||||
:model-value="customerNumber || ''"
|
||||
|
||||
@@ -137,7 +137,7 @@ watch(() => props.selectedVehicleTypeId, (newId) => {
|
||||
</template>
|
||||
<div class="column is-full mt-3">
|
||||
<div class="vehicle-type-description" data-testid="self-serve-vehicle-selector-description">
|
||||
<p class="has-text-centered p-2">
|
||||
<p class="has-text-left p-2">
|
||||
<template v-if="filteredVehicleTypes.some(type => type.selected)">
|
||||
Du har valgt:
|
||||
<strong>{{ filteredVehicleTypes.find(type => type.selected)?.name }}</strong>
|
||||
@@ -166,6 +166,15 @@ watch(() => props.selectedVehicleTypeId, (newId) => {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.vehicle-type-content :deep(.box),
|
||||
.vehicle-type-content :deep(.card) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vehicle-type-content img {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vehicle-type-content:nth-child(3n + 1) {
|
||||
padding-left: 0;
|
||||
}
|
||||
@@ -178,8 +187,12 @@ watch(() => props.selectedVehicleTypeId, (newId) => {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 5px;
|
||||
min-height: 64px;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vehicle-type-description p {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,9 +9,13 @@ const props = defineProps({
|
||||
subtitle: String,
|
||||
icon: String,
|
||||
isSkeleton: Boolean,
|
||||
defaultExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const isExpanded = ref(props.defaultExpanded);
|
||||
const skeletonSpaces = (length) => {
|
||||
// Create a string with non-breaking spaces
|
||||
return Array.from({ length }, () => '\u00A0').join('');
|
||||
@@ -58,4 +62,4 @@ const skeletonSpaces = (length) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -47,6 +47,7 @@ const moduleConfigPaths = {
|
||||
shelly: "/superuser/configuration/shelly",
|
||||
selfserve: "/superuser/configuration/selfserve",
|
||||
bird: "/superuser/configuration/bird",
|
||||
coolify: "/superuser/configuration/coolify",
|
||||
};
|
||||
|
||||
const snapshot = computed(() => SuperUserSystemStatusObject.snapshot.value);
|
||||
@@ -119,7 +120,7 @@ const gatewayCards = computed(() => {
|
||||
lastHeartbeatLabel: formatDate(gateway?.last_heartbeat_at),
|
||||
activeOperationLabel: gatewayOperationLabel(activeOperation),
|
||||
message: gatewayPrimaryMessage(gateway),
|
||||
link: `/superuser/configuration/edgegateway/${encodeURIComponent(String(gateway?.id ?? ""))}/overview`,
|
||||
link: `/superuser/selfserve/edge-agents/${encodeURIComponent(String(gateway?.id ?? ""))}/overview`,
|
||||
};
|
||||
})
|
||||
.filter((gateway) => gateway.id !== null);
|
||||
@@ -133,6 +134,7 @@ const statusCards = computed(() => [
|
||||
primary: dependencies.value?.database?.database || "MySQL",
|
||||
secondary: dependencies.value?.database?.server_version || "--",
|
||||
detail: formatLatency(dependencies.value?.database?.latency_ms),
|
||||
replicationLabel: formatReplicationLabel(dependencies.value?.database?.replication),
|
||||
},
|
||||
{
|
||||
id: "redis",
|
||||
@@ -141,6 +143,7 @@ const statusCards = computed(() => [
|
||||
primary: dependencies.value?.redis?.database ?? "--",
|
||||
secondary: t("system_status.labels.database_index"),
|
||||
detail: formatLatency(dependencies.value?.redis?.latency_ms),
|
||||
replicationLabel: formatReplicationLabel(dependencies.value?.redis?.replication),
|
||||
},
|
||||
{
|
||||
id: "minio",
|
||||
@@ -149,6 +152,7 @@ const statusCards = computed(() => [
|
||||
primary: dependencies.value?.minio?.endpoint || "--",
|
||||
secondary: formatMinioBuckets(dependencies.value?.minio?.buckets),
|
||||
detail: formatLatency(dependencies.value?.minio?.latency_ms),
|
||||
replicationLabel: formatReplicationLabel(dependencies.value?.minio?.replication),
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
@@ -484,6 +488,40 @@ function formatUsage(value) {
|
||||
return `${Number(value).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatReplicationLabel(replication) {
|
||||
if (!replication || typeof replication !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percent = Number(replication.average_percent ?? replication.min_percent ?? 0);
|
||||
const status = statusLabel(replication.status || "unknown");
|
||||
const label = t("system_status.labels.replication_percent", {
|
||||
percent: Number.isFinite(percent) ? percent.toFixed(1) : "0.0",
|
||||
status,
|
||||
});
|
||||
const coolify = coolifyAvailabilityForReplication(replication);
|
||||
|
||||
return coolify ? `${label} - Coolify ${coolify}` : label;
|
||||
}
|
||||
|
||||
function coolifyAvailabilityForReplication(replication) {
|
||||
const hosts = [
|
||||
...(Array.isArray(replication.replicas) ? replication.replicas : []),
|
||||
...(Array.isArray(replication.hosts) ? replication.hosts : []),
|
||||
replication.primary,
|
||||
].filter(Boolean);
|
||||
const host = hosts.find((entry) => entry?.coolify?.availability_state || entry?.coolify?.deployment_status);
|
||||
if (!host) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const availability = String(host.coolify?.availability_state || host.coolify?.deployment_status || "").trim();
|
||||
if (availability === "") {
|
||||
return "";
|
||||
}
|
||||
return availability.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||
return "--";
|
||||
@@ -672,6 +710,9 @@ function modulePath(key) {
|
||||
<section class="system-status-section">
|
||||
<div class="section-heading">
|
||||
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
|
||||
<RouterLink class="section-link" to="/superuser/system/replication">
|
||||
{{ $t("system_status.actions.open_replication") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="status-card-grid">
|
||||
<article
|
||||
@@ -688,6 +729,13 @@ function modulePath(key) {
|
||||
<strong class="status-card__primary">{{ card.primary }}</strong>
|
||||
<p class="status-card__secondary">{{ card.secondary }}</p>
|
||||
<small class="status-card__detail">{{ card.detail }}</small>
|
||||
<small
|
||||
v-if="card.replicationLabel"
|
||||
class="status-card__detail status-card__replication"
|
||||
:data-testid="`status-card-${card.id}-replication`"
|
||||
>
|
||||
{{ card.replicationLabel }}
|
||||
</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -702,7 +750,7 @@ function modulePath(key) {
|
||||
<h3>{{ $t("system_status.sections.gateways") }}</h3>
|
||||
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
|
||||
</div>
|
||||
<RouterLink class="section-link" to="/superuser/configuration/edgegateway">
|
||||
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
|
||||
{{ $t("system_status.gateways.actions.open_fleet") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
@@ -965,6 +1013,10 @@ function modulePath(key) {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.status-card__detail {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.module-card {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
|
||||
@@ -72,7 +72,7 @@ const parseClosedAt = (value) => {
|
||||
<ActionSettingsWheelItem
|
||||
icon="fas fa-eye"
|
||||
@click="redirect('/superuser/invoices/' + object.id)"
|
||||
:label="t('global.show')"
|
||||
:label="t('common.show')"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
|
||||
@@ -272,7 +272,7 @@ const getCustomerUserId = (object) => {
|
||||
<ActionSettingsWheelItem
|
||||
icon="fas fa-eye"
|
||||
@click="redirect('/superuser/invoices/' + object.id)"
|
||||
:label="t('global.show')"
|
||||
:label="t('common.show')"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
|
||||
@@ -203,7 +203,7 @@ const isOrderContentVisible = (order) => {
|
||||
class="button is-small"
|
||||
@click="toggleAllOrders()"
|
||||
>
|
||||
{{ isAnyOrderSelected() ? $t('global.unselect') + ' ' + $t('global.all').toLowerCase() : $t('global.select') + ' ' + $t('global.all').toLowerCase() }}
|
||||
{{ isAnyOrderSelected() ? $t('global.unselect') + ' ' + $t('common.all').toLowerCase() : $t('common.select') + ' ' + $t('common.all').toLowerCase() }}
|
||||
</button>
|
||||
<!-- Invoice -->
|
||||
<button
|
||||
|
||||
@@ -122,7 +122,7 @@ getDepartments();
|
||||
<ActionSettingsWheelItem
|
||||
icon="fas fa-eye"
|
||||
@click="redirect('/superuser/invoices/' + object.id)"
|
||||
:label="t('global.show')"
|
||||
:label="t('common.show')"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user