Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard fa1e915b9c Merge origin/master into Selvvask period fix 2026-07-06 16:07:05 +02:00
Jeppe B 5cc22f14bc Fix date-only selection handling
Use local date-only parsing and formatting across date inputs and filters so clicked dates persist exactly.\n\nVerified with local format, lint, i18n, build, full unit tests, PR E2E, focused date E2E, broad smoke reruns, and GitHub PR checks.
2026-07-06 15:52:19 +02:00
Jeppe B 8519af5cbb Merge pull request #141 from copenhagentruckwash/codex/custom-pricing-only-departments
Add custom-only department pricing toggle
2026-07-06 15:31:41 +02:00
Jeppe BandJeppe Bundgaard 3fc4be8335 Update limited backoffice employee form (#146)
* Update limited backoffice employee form

* Map limited backoffice changes to focused E2E

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 15:31:16 +02:00
Jeppe Bundgaard c41954993c Fix PR E2E changed diff head 2026-07-06 14:54:41 +02:00
Jeppe Bundgaard 36c1f10de8 Reduce PR E2E runner pressure 2026-07-06 14:47:03 +02:00
Jeppe Bundgaard 4378dad2b9 Stabilize PR E2E runner checks 2026-07-06 14:39:06 +02:00
Jeppe Bundgaard ee41366c08 Bound changed PR E2E selection 2026-07-06 13:57:06 +02:00
Jeppe Bundgaard e22f78a449 Stabilize unit timer cleanup 2026-07-06 13:06:41 +02:00
Jeppe Bundgaard 0e9292d6d9 Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	scripts/playwright-pr-mapping.mjs
2026-07-06 12:48:54 +02:00
Jeppe Bundgaard 69250ada66 Stabilize self-serve restored lane smoke test 2026-07-06 12:44:26 +02:00
Jeppe Bundgaard ce844137a0 Harden POS order detail E2E wait 2026-07-06 12:18:20 +02:00
Jeppe Bundgaard 7f3a8c07e2 Stabilize edge gateway installer smoke tests 2026-07-06 11:47:58 +02:00
Jeppe Bundgaard bd14d58f08 Stabilize self-serve wash smoke assertion 2026-07-06 11:03:22 +02:00
Jeppe Bundgaard b8494cd1d9 Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 10:36:57 +02:00
Jeppe Bundgaard 1a1c3db8e5 Fix invoicing period selfwash counts 2026-07-06 10:33:48 +02:00
Jeppe Bundgaard 49772c1334 Trigger frontend PR checks 2026-07-06 10:30:43 +02:00
Jeppe Bundgaard 02ddb99000 Add custom-only department pricing toggle 2026-07-06 10:21:06 +02:00
72 changed files with 1421 additions and 198 deletions
+10 -2
View File
@@ -102,7 +102,7 @@ jobs:
needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 30
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: 4
@@ -112,6 +112,8 @@ jobs:
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_VIDEO_MODE: on-first-retry
steps:
- name: Repair self-hosted workspace permissions
shell: bash
@@ -140,20 +142,24 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
HEAD_SHA: ${{ github.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
zero_sha="0000000000000000000000000000000000000000"
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
base_ref="$PR_BASE_SHA"
head_ref="$PR_HEAD_SHA"
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
base_ref="origin/$DEFAULT_BRANCH"
head_ref="$HEAD_SHA"
else
base_ref="$PUSH_BEFORE_SHA"
head_ref="$HEAD_SHA"
fi
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v5
@@ -223,6 +229,8 @@ jobs:
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_SUITE="$MATRIX_SUITE" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
+21 -2
View File
@@ -7,14 +7,18 @@ export const fallbackChangePatterns = [
/^vite\.config\.js$/u,
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
/^scripts\/run-playwright-(?:pr|ci-parallel|batched-chromium)\.mjs$/u,
/^scripts\/run-playwright-(?:ci-parallel|batched-chromium)\.mjs$/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],
patterns: [
/^src\/(?:views|components|middleware)\/.*auth/iu,
/^src\/views\/auth\//u,
/^src\/components\/session\/(?!token\/SessionUser\/Objects\/)/u,
],
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
projects: chromiumProjects,
},
@@ -70,6 +74,12 @@ export const sourceMappings = [
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
projects: chromiumProjects,
},
{
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "invoicing",
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
@@ -92,6 +102,15 @@ export const sourceMappings = [
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "superuser-department-pricing",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|SuperUserSelectedDepartmentObject)\.vue$/u,
/^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u,
],
specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"],
projects: ["chromium-desktop"],
},
{
name: "self-serve",
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
+1
View File
@@ -103,6 +103,7 @@ export const ownedFilesByRole = {
"superuser-department-branding.spec.js",
"superuser-department-gates.spec.ts",
"superuser-department-lanes.spec.ts",
"superuser-department-pricing-custom-only.spec.ts",
"superuser-departments-archive.spec.ts",
"superuser-drafts.spec.ts",
"superuser-products-layout.spec.ts",
+24 -4
View File
@@ -261,6 +261,8 @@ function selectChangedTests(changedFiles) {
specProjects: new Map(),
mappedFiles: [],
unmappedFiles: [],
directSpecFiles: [],
skippedDirectSpecFiles: [],
fallback: false,
};
@@ -270,8 +272,7 @@ function selectChangedTests(changedFiles) {
const file = normalizePath(rawFile);
if (isE2eSpec(file)) {
addSpec(selection, file, selectedProjects);
selection.mappedFiles.push(file);
selection.directSpecFiles.push(file);
continue;
}
@@ -295,13 +296,26 @@ function selectChangedTests(changedFiles) {
selection.mappedFiles.push(file);
for (const mapping of matches) {
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
const mappedProjects = mapping.projects.length > 0 ? mapping.projects : selectedProjects;
const projects = mappedProjects.filter((project) => selectedProjects.includes(project));
if (projects.length === 0) {
continue;
}
for (const spec of mapping.specs) {
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
addSpec(selection, spec, projects);
}
}
}
if (selection.specProjects.size === 0 && !selection.fallback) {
for (const file of selection.directSpecFiles) {
addSpec(selection, file, selectedProjects);
selection.mappedFiles.push(file);
}
} else {
selection.skippedDirectSpecFiles.push(...selection.directSpecFiles);
}
return selection;
}
@@ -370,6 +384,12 @@ async function runChangedSelection(selection) {
return 0;
}
if (selection.skippedDirectSpecFiles.length > 0) {
console.log(
`[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(", ")}`
);
}
for (const [index, group] of groups.entries()) {
for (const project of group.projects) {
const code = await runPlaywright({
@@ -5,6 +5,7 @@ import { useWindowSize } from "@vueuse/core";
import { BMessage } from "buefy";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
type DateRange = {
startDate: Date,
@@ -115,23 +116,11 @@ const availableYears = computed(() => (
));
const formatDateInputValue = (date: Date) => {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "";
}
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}`;
return formatLocalDateOnly(date);
};
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]));
return parseLocalDateOnly(value);
};
const isSameDateInputValue = (left: Date, right: Date) => (
@@ -30,6 +30,7 @@ import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/
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 { todayLocalDateOnly } from "@/services/dateOnly.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
import {
@@ -377,8 +378,8 @@ const fetchDuplicateOrdersForContext = async (context) => {
try {
const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", {
filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${
new Date().toISOString().split("T")[0]
},created_at-date_to:${new Date().toISOString().split("T")[0]}`,
todayLocalDateOnly()
},created_at-date_to:${todayLocalDateOnly()}`,
limit: 5,
});
@@ -12,6 +12,7 @@ import PosDepartmentStepMobileButtonNextStep from "@/components/displays/departm
import SessionUser from "@/components/session/token/SessionUser.vue";
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
/** Display variables */
@@ -126,7 +127,7 @@ const liveTransactions = ref(null);
const isLoading = ref(true);
const syncListTransactionHistory = async () => {
let dateToday = new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
let dateToday = todayLocalDateOnly(); // Get today's date in YYYY-MM-DD format
isLoading.value = true;
// Fetch the list of orders created today for the current department
SessionUser.objects.orders.get
@@ -22,7 +22,7 @@ const props = defineProps({
import {computed, ref, watch} from 'vue';
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.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";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import Swal from "sweetalert2";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
@@ -38,6 +38,8 @@ import {
setCachedXlvaskUsageAmount
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
const redirectDepartmentOrderPage = (orderId, departmentId) => {
// Send the user to the order page (In a new tab)
// `/admin/${departmentId}/modules/pos/orders/${orderId}`
@@ -0,0 +1 @@
export const SELFWASH_PERIOD_ALL_LIMIT = 10000;
@@ -1,6 +1,7 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatLocalDateOnly } from '@/services/dateOnly.js';
type startEndDate = {
start: Date; // The start date of the range
end: Date; // The end date of the range
@@ -27,7 +28,7 @@ const datePresetFunctions = {
month: {
// Get the first day of the month based on the provided date first day at 00:00:01
firstDayOfMonth: (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), 2, 0, 0, 1, 0); // Set to the first day of the month at 00:00:01
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0); // Set to the first day of the month at 00:00:00
},
// Get the last day of the month based on the provided date last day at 23:59:59
lastDayOfMonth: (date: Date): Date => {
@@ -116,7 +117,7 @@ export const datePresets = <datePreset[]>[
*/
const convertToISO = (date: Date): string => {
return date.toISOString().split('T')[0]; // Convert to YYYY-MM-DD format
return formatLocalDateOnly(date); // Convert to YYYY-MM-DD format
};
export const dateFunctions = {
@@ -150,4 +151,4 @@ export default defineComponent({
},
});
</script>
<template></template>
<template></template>
@@ -21,9 +21,32 @@ const props = defineProps({
type: Number, default: 0
}
})
import {computed, ref, watch } from "vue";
import {computed, provide, ref, watch } from "vue";
import { useRouter } from "vue-router";
import {
PaginatedListKey,
usePaginatedList,
} from "@/components/pagination/paginatedList.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
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";
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
@@ -38,22 +61,8 @@ import {
setOrder,
hideSearchField,
setHideSearchField,
} from "@/components/pagination/paginatedList.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
} = paginatedList;
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
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);
@@ -63,7 +72,7 @@ const parseInitialDate = (value) => {
return new Date();
}
const parsed = new Date(`${value}T00:00:00`);
const parsed = parseLocalDateOnly(value);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
};
@@ -129,7 +138,7 @@ const dateFrom = ref(parseInitialDate(props.initialDateFrom));
const dateTo = ref(parseInitialDate(props.initialDateTo));
const parsedDate = (date) => {
return new Date(date);
return parseLocalDateOnly(date);
};
const reloadScheduled = ref(false);
@@ -148,14 +157,14 @@ const actions = {
from: {
select: (date) => {
dateFrom.value = parsedDate(date);
setFilter("StartTime-date_from", date.toISOString().split("T")[0], false);
setFilter("StartTime-date_from", formatLocalDateOnly(date), false);
scheduleReload();
}
},
to: {
select: (date) => {
dateTo.value = parsedDate(date);
setFilter("StartTime-date_to", date.toISOString().split("T")[0], false);
setFilter("StartTime-date_to", formatLocalDateOnly(date), false);
scheduleReload();
}
},
@@ -109,8 +109,8 @@ import OrdersTable from "@/components/displays/department/pos/orders/ordersTable
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
import {now} from "@vueuse/core";
import { useI18n } from 'vue-i18n'
import { formatLocalDateOnly, parseLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
@@ -176,8 +176,8 @@ if (props.autoLoad) {
const date_from = ref(null);
const date_to = ref(null);
const onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {
const formattedStartDate = newSelectionStartDate.toISOString().split("T")[0];
const formattedEndDate = newSelectionToDate.toISOString().split("T")[0];
const formattedStartDate = formatLocalDateOnly(newSelectionStartDate);
const formattedEndDate = formatLocalDateOnly(newSelectionToDate);
date_from.value = formattedStartDate;
date_to.value = formattedEndDate;
setFilter("created_at-date_from", formattedStartDate, true);
@@ -361,7 +361,7 @@ const doesEndpointMatch = (matcher) => {
<!-- Shortcuts for date filters -->
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
v-bind:selection="{ startDate: date_from ? new Date(date_from) : new Date(now()), endDate: date_to ? new Date(date_to) : new Date(now()) }"/>
v-bind:selection="{ startDate: parseLocalDateOnly(date_from || todayLocalDateOnly()), endDate: parseLocalDateOnly(date_to || todayLocalDateOnly()) }"/>
</div>
</template>
<template #default>
@@ -28,6 +28,7 @@ import {ref, watch} from "vue";
import { Colors } from "@/ThemeConfig.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n'
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
@@ -52,7 +53,7 @@ watch(() => getFilter("status"), (value) => {
// If the route starts with /user, set the endpoint to /user/bookings
if (router.currentRoute.value.path.startsWith("/user")) {
setEndpoint("/user/bookings", false);
setFilter("date", new Date().toISOString().split("T")[0], false);
setFilter("date", todayLocalDateOnly(), false);
setOrder("created_at", "desc");
}
// If the route starts with /superuser, set the endpoint to /bookings
@@ -81,7 +82,7 @@ loadList();
const showingToday = ref(true);
watch(() => getFilter("date"), (value) => {
showingToday.value = value === new Date().toISOString().split("T")[0];
showingToday.value = value === todayLocalDateOnly();
});
// If the screen is mobile, set the is small variable to true
@@ -151,7 +152,7 @@ const showNewOrderBookingsPortal = () => {
<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 }" />
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? todayLocalDateOnly() : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
<label for="today"></label>
</div>
</div>
@@ -188,4 +189,4 @@ const showNewOrderBookingsPortal = () => {
<style scoped>
</style>
</style>
@@ -8,6 +8,7 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { Colors } from "@/ThemeConfig.vue";
import { useI18n } from "vue-i18n";
import { endOfLocalDate, startOfLocalDate, todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n();
/**
@@ -52,11 +53,9 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
setFilter("datetime-date_from", null, false);
setFilter("datetime-date_to", null, false);
} else {
const startOfDay = new Date().setHours(0, 0, 0, 0);
const endOfDay = new Date().setHours(23, 59, 59, 999);
//setFilter('datetime', null, false);
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
setFilter("datetime-date_from", startOfLocalDate(val).toISOString(), false);
setFilter("datetime-date_to", endOfLocalDate(val).toISOString(), false);
}
if (autoLoadList) {
loadList();
@@ -95,7 +94,7 @@ onMounted(() => {
departmentFilter.value = value;
} else if (key === "only_today" && value === true) {
setFilterKey = false; // Since the only_today filter is handled separately
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
onOnlyTodayFilterChange({ target: { value: todayLocalDateOnly() } }, false);
}
if (setFilterKey) {
setFilter(key, value, false);
@@ -170,7 +169,7 @@ onMounted(() => {
<div class="select">
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
<option value="*">{{ t("common.all") }}</option>
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
<option :value="todayLocalDateOnly()">{{ t("common.yes") }}</option>
</select>
</div>
</div>
@@ -193,7 +192,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange({
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
target: { value: event.target.checked ? todayLocalDateOnly() : '*' },
});
}
"
@@ -226,7 +225,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange(
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
{ target: { value: event.target.checked ? todayLocalDateOnly() : '*' } },
false
);
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
@@ -1,6 +1,7 @@
<script setup lang="ts">
import PaginationDisplayItemColumn from "@/components/displays/pagination/PaginationDisplayItemColumn.vue";
import { ref, computed } from "vue";
import { computed } from "vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const props = defineProps({
label: {
type: String,
@@ -19,7 +20,7 @@ defineExpose({
});
const formattedDate = computed(() => {
return date.value.toISOString().split('T')[0];
return formatLocalDateOnly(date.value);
});
// This component is used to display a date input in a pagination display item column.
@@ -36,8 +37,7 @@ const formattedDate = computed(() => {
class="input"
:value="formattedDate"
@input="(e) => {
const newDate = new Date(e.target.value);
console.log('Selected date:', newDate);
const newDate = parseLocalDateOnly(e.target.value);
emit('update:date', newDate);
}"
/>
@@ -47,4 +47,4 @@ const formattedDate = computed(() => {
<style scoped>
</style>
</style>
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
@@ -10,7 +9,7 @@ import PaginationDisplayTemplateButton
from "@/components/displays/pagination/templates/PaginationDisplayTemplateButton.vue";
import { datePresets , dateFunctions} from '@/components/displays/pagination/PaginationDisplayDates.vue';
const props = defineProps({
defineProps({
startDate: {
type: Date,
required: true,
@@ -46,6 +45,7 @@ function updateStartDate(date: Date) {
// Update the end date model when the date is changed
function updateEndDate(date: Date) {
endDateModel.value = date;
emits('update:endDate', date);
}
</script>
@@ -79,4 +79,4 @@ function updateEndDate(date: Date) {
<style scoped>
</style>
</style>
@@ -13,6 +13,7 @@ import { showDownloadWashCertificate } from "@/components/shop/DownloadWashCerti
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import Swal from 'sweetalert2';
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { todayLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
const redirectBookingObjectPage = (objectId) => {
// Send the user to the object page
window.location.href = `/user/bookings/${objectId}`;
@@ -237,7 +238,8 @@ window.addEventListener('resize', () => {
* To organize the bookings, we want to show the bookings that are created today first, then yesterday, then all other days
* @type {string}
*/
const currentDate = new Date().toISOString().split("T")[0];
const currentDate = todayLocalDateOnly();
const previousDate = yesterdayLocalDateOnly();
/**
* Sort the bookings by date
@@ -362,7 +364,7 @@ const canUserEditObject = (object) => {
</span>
<span class="has-text-grey">
<!-- Human readable date (Today, Yesterday, etc.) -->
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split("T")[0] ? $t('tables.bookings.yesterday') : object.date }}
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === previousDate ? $t('tables.bookings.yesterday') : object.date }}
<!-- Number of bookings on the date -->
({{ $t('tables.bookings.bookings_count', { count: objects.filter((booking) => booking.date === object.date).length }) }})
</span>
+4 -3
View File
@@ -4,6 +4,7 @@ import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
// Get the department ID from the URL
const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -73,9 +74,9 @@ const emphasis = ref({
"department:" +
department_id +
",status:pending,date-date_from:" +
new Date().toISOString().split("T")[0] +
todayLocalDateOnly() +
",date-date_to:" +
new Date().toISOString().split("T")[0],
todayLocalDateOnly(),
limit: 1, // Limit to 1 booking, we only need to know if there are any bookings or not
page: 1,
})
@@ -97,7 +98,7 @@ const emphasis = ref({
* Check if the department has a daily report for today
*/
SessionUser.request(SessionUser.objects.department_daily_reports.meta.endpoint + "/get", "GET", {
date: new Date().toISOString().split("T")[0],
date: todayLocalDateOnly(),
id: department_id, // This refers to the department ID
})
.then((response) => {
@@ -5,6 +5,7 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import i18n from '@/i18n';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const t = (key) => i18n.global.t(key);
@@ -241,7 +242,7 @@ export const Bookings = {
'/bookings',
'GET',
{
filters: `department:${department},status:pending,date-date_from:${new Date().toISOString().split('T')[0]},date-date_to:${new Date().toISOString().split('T')[0]}`,
filters: `department:${department},status:pending,date-date_from:${todayLocalDateOnly()},date-date_to:${todayLocalDateOnly()}`,
limit: 100,
page: 1
}
@@ -289,4 +290,4 @@ export const Bookings = {
);
}
};
</script>
</script>
@@ -5,6 +5,7 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import i18n from '@/i18n';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const t = (key) => i18n.global.t(key);
@@ -80,7 +81,7 @@ const t = (key) => i18n.global.t(key);
water_usage_morning: parseInt(water_usage_morning),
water_usage: parseInt(water_usage),
notes: notes ? notes : 'Daily report',
date: date ? date : new Date().toISOString().split('T')[0]
date: date ? date : todayLocalDateOnly()
}
);
},
@@ -1,6 +1,7 @@
<script>
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
/**
* The DepartmentTimeBookingsEntries object
@@ -230,10 +231,10 @@ export const DepartmentTimeBookingsEntries = {
if (!dateFrom) {
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
dateFrom = lastWeek.toISOString().split('T')[0]; // Format as YYYY-MM-DD
dateFrom = formatLocalDateOnly(lastWeek); // Format as YYYY-MM-DD
}
if (!dateTo) {
dateTo = new Date().toISOString().split('T')[0]; // Format as YYYY-MM-DD
dateTo = todayLocalDateOnly(); // Format as YYYY-MM-DD
}
console.log("Fetching public available times for department:", departmentId, "from", dateFrom, "to", dateTo);
// Make the request to fetch public available times for the department
@@ -279,4 +280,4 @@ export const DepartmentTimeBookingsEntries = {
);
}
};
</script>
</script>
@@ -108,6 +108,14 @@ export const Departments = {
required: false,
},
},
custom_pricing_only: {
label: t("objects.departments.columns.custom_pricing_only"),
type: "boolean",
sortable: true,
creation: {
required: false,
},
},
longitude: {
label: t("objects.departments.columns.longitude"),
type: "number",
@@ -164,6 +172,14 @@ export const Departments = {
archived: async (id, archived) => {
return ObjectsGlobal.set.column(Departments.meta.endpoint, id, "archived", ObjectsGlobal.parse.boolean(archived));
},
custom_pricing_only: async (id, custom_pricing_only) => {
return ObjectsGlobal.set.column(
Departments.meta.endpoint,
id,
"custom_pricing_only",
ObjectsGlobal.parse.boolean(custom_pricing_only)
);
},
longitude: async (id, longitude) => {
return ObjectsGlobal.set.column(Departments.meta.endpoint, id, "longitude", parseFloat(longitude));
},
+24
View File
@@ -1190,6 +1190,14 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -3455,6 +3463,13 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Kun egne priser",
"custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"effective_department_price": "Effektiv afdelingspris"
},
"search_placeholder": "@.capitalize:{'words.generated.søg'} @:{'words.generated.efter'} @:{'words.generated.afdelingsnavn'}",
"select_department": "@.capitalize:{'words.generated.vælg'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.afdeling'}",
"subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.afdelinger'}",
@@ -4285,6 +4300,7 @@
"departments": {
"columns": {
"archived": "Arkiveret",
"custom_pricing_only": "Ingen fallback-priser",
"dimension": "Dimension",
"economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.afdeling'}",
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
@@ -6024,6 +6040,14 @@
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbejder'}",
"name": "Navn",
"email": "Email",
"phone": "Telefon",
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.adgangskode'}",
"password_create_help": "@.capitalize:{'words.generated.pakrævet'} @:{'words.generated.for'} nye @:{'words.generated.medarbejdere'}. Brug @:{'words.generated.mindst'} 8 tegn.",
"password_update_help": "Lad feltet være tomt @:{'words.generated.for'} at beholde den nuværende @:{'words.generated.adgangskode'}.",
+16
View File
@@ -1301,6 +1301,14 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -6121,6 +6129,14 @@
"edit_title": "@:{'words.generated.mitarbeiter'} @:{'words.generated.bearbeiten'}",
"name": "Name",
"email": "E-Mail",
"phone": "Telefon",
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
"country_codes": {
"denmark": "Dänemark",
"sweden": "Schweden",
"norway": "Norwegen",
"finland": "Finnland"
},
"password": "@:{'words.generated.passwort'}",
"password_create_help": "@.capitalize:{'words.generated.fur'} neue @:{'words.generated.mitarbeiter'} @:{'words.generated.erforderlich'}. Verwenden @.capitalize:{'words.generated.sie'} @:{'words.generated.mindestens'} 8 Zeichen.",
"password_update_help": "Leer lassen, um das vorhandene @:{'words.generated.passwort'} beizubehalten.",
+24
View File
@@ -1022,6 +1022,14 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -3287,6 +3295,13 @@
"opening_hours": {
"closed": "@:{'templates.generated.compat.global.closed'}"
},
"pricing": {
"custom_pricing_disabled": "Fallback enabled",
"custom_pricing_enabled": "Custom only",
"custom_pricing_missing_price": "Missing department prices resolve to 999999.",
"custom_pricing_only": "No fallback pricing",
"effective_department_price": "Effective department price"
},
"search_placeholder": "@.capitalize:{'words.generated.search'} @:{'words.generated.by'} @:{'words.generated.department'} @:{'words.generated.name'}",
"select_department": "@.capitalize:{'words.generated.select'} @:{'words.generated.a'} @:{'words.generated.department'}",
"subtitle": "@.capitalize:{'words.generated.manage'} @:{'words.generated.departments'}",
@@ -4117,6 +4132,7 @@
"departments": {
"columns": {
"archived": "@:{'words.generated.archived'}",
"custom_pricing_only": "No fallback pricing",
"dimension": "Dimension",
"economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @.capitalize:{'words.generated.department'}",
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
@@ -5856,6 +5872,14 @@
"edit_title": "@.capitalize:{'words.generated.edit'} @:{'words.generated.employee'}",
"name": "Name",
"email": "Email",
"phone": "Phone",
"phone_validation": "Phone number must be 4-15 digits.",
"country_codes": {
"denmark": "Denmark",
"sweden": "Sweden",
"norway": "Norway",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.password'}",
"password_create_help": "@.capitalize:{'words.generated.required'} @:{'words.generated.for'} new @:{'words.generated.employees'}. Use @:{'words.generated.at'} @:{'words.generated.least'} 8 characters.",
"password_update_help": "Leave blank @:{'words.generated.to'} keep @:{'words.replication.article.host_mention'} existing @:{'words.generated.password'}.",
+8
View File
@@ -2420,6 +2420,13 @@
"title": "@:{'templates.generated.compat.departments.tab.opening_hours'}"
},
"phone": "@:common.phone",
"pricing": {
"custom_pricing_disabled": "@:{'templates.generated.compat.departments.pricing.custom_pricing_disabled'}",
"custom_pricing_enabled": "@:{'templates.generated.compat.departments.pricing.custom_pricing_enabled'}",
"custom_pricing_missing_price": "@:{'templates.generated.compat.departments.pricing.custom_pricing_missing_price'}",
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
"effective_department_price": "@:{'templates.generated.compat.departments.pricing.effective_department_price'}"
},
"products": {
"new_product": "@:products.new_product"
},
@@ -3486,6 +3493,7 @@
"departments": {
"columns": {
"archived": "@:{'templates.generated.compat.objects.departments.columns.archived'}",
"custom_pricing_only": "@:{'templates.generated.compat.objects.departments.columns.custom_pricing_only'}",
"dimension": "@:{'templates.generated.compat.objects.departments.columns.dimension'}",
"economic_department": "@:{'templates.generated.compat.objects.departments.columns.economic_department'}",
"latitude": "@:{'templates.generated.compat.objects.departments.columns.latitude'}",
+16
View File
@@ -1304,6 +1304,14 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -6124,6 +6132,14 @@
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbeider'}",
"name": "Navn",
"email": "E-post",
"phone": "Telefon",
"phone_validation": "Telefonnummeret må være 4-15 sifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.passord'}",
"password_create_help": "@.capitalize:{'words.generated.kreves'} @:{'words.generated.for'} nye @:{'words.generated.medarbeidere'}. Bruk @:{'words.generated.minst'} 8 tegn.",
"password_update_help": "La stå tomt @:{'words.generated.for'} å beholde nåværende @:{'words.generated.passord'}.",
+16
View File
@@ -1354,6 +1354,14 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -6174,6 +6182,14 @@
"edit_title": "@:{'words.generated.redigera'} @:{'words.generated.medarbetare'}",
"name": "Namn",
"email": "E-post",
"phone": "Telefon",
"phone_validation": "Telefonnumret måste vara 4-15 siffror.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.losenord'}",
"password_create_help": "@.capitalize:{'words.generated.kravs'} @:{'words.generated.for'} nya @:{'words.generated.medarbetare'}. Använd @:{'words.generated.minst'} 8 tecken.",
"password_update_help": "Lämna tomt @:{'words.generated.for'} att behålla nuvarande @:{'words.generated.losenord'}.",
@@ -21,6 +21,13 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Kun egne priser",
"custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"effective_department_price": "Effektiv afdelingspris"
},
"search_placeholder": "@.capitalize:{'terms.glossary.søg'} @:{'terms.glossary.efter'} @:{'terms.glossary.afdelingsnavn'}",
"select_department": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.afdeling'}",
"subtitle": "@.capitalize:{'terms.glossary.administrer'} @:{'terms.glossary.afdelinger'}",
@@ -140,6 +140,7 @@
"departments": {
"columns": {
"archived": "Arkiveret",
"custom_pricing_only": "Ingen fallback-priser",
"dimension": "Dimension",
"economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.afdeling'}",
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
+16
View File
@@ -46,6 +46,14 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -279,6 +287,14 @@
"edit_title": "@:{'terms.glossary.rediger'} @:{'terms.glossary.medarbejder'}",
"name": "Navn",
"email": "Email",
"phone": "Telefon",
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.adgangskode'}",
"password_create_help": "@.capitalize:{'terms.glossary.pakrævet'} @:{'terms.glossary.for'} nye @:{'terms.glossary.medarbejdere'}. Brug @:{'terms.glossary.mindst'} 8 tegn.",
"password_update_help": "Lad feltet være tomt @:{'terms.glossary.for'} at beholde den nuværende @:{'terms.glossary.adgangskode'}.",
+16
View File
@@ -46,6 +46,14 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -279,6 +287,14 @@
"edit_title": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.bearbeiten'}",
"name": "Name",
"email": "E-Mail",
"phone": "Telefon",
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
"country_codes": {
"denmark": "Dänemark",
"sweden": "Schweden",
"norway": "Norwegen",
"finland": "Finnland"
},
"password": "@:{'terms.glossary.passwort'}",
"password_create_help": "@.capitalize:{'terms.glossary.fur'} neue @:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.erforderlich'}. Verwenden @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.mindestens'} 8 Zeichen.",
"password_update_help": "Leer lassen, um das vorhandene @:{'terms.glossary.passwort'} beizubehalten.",
@@ -21,6 +21,13 @@
"opening_hours": {
"closed": "@:{'phrases.compat.global.closed'}"
},
"pricing": {
"custom_pricing_disabled": "Fallback enabled",
"custom_pricing_enabled": "Custom only",
"custom_pricing_missing_price": "Missing department prices resolve to 999999.",
"custom_pricing_only": "No fallback pricing",
"effective_department_price": "Effective department price"
},
"search_placeholder": "@.capitalize:{'terms.glossary.search'} @:{'terms.glossary.by'} @:{'terms.glossary.department'} @:{'terms.glossary.name'}",
"select_department": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.a'} @:{'terms.glossary.department'}",
"subtitle": "@.capitalize:{'terms.glossary.manage'} @:{'terms.glossary.departments'}",
@@ -140,6 +140,7 @@
"departments": {
"columns": {
"archived": "@:{'terms.glossary.archived'}",
"custom_pricing_only": "No fallback pricing",
"dimension": "Dimension",
"economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @.capitalize:{'terms.glossary.department'}",
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
+16
View File
@@ -46,6 +46,14 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -279,6 +287,14 @@
"edit_title": "@.capitalize:{'terms.glossary.edit'} @:{'terms.glossary.employee'}",
"name": "Name",
"email": "Email",
"phone": "Phone",
"phone_validation": "Phone number must be 4-15 digits.",
"country_codes": {
"denmark": "Denmark",
"sweden": "Sweden",
"norway": "Norway",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.password'}",
"password_create_help": "@.capitalize:{'terms.glossary.required'} @:{'terms.glossary.for'} new @:{'terms.glossary.employees'}. Use @:{'terms.glossary.at'} @:{'terms.glossary.least'} 8 characters.",
"password_update_help": "Leave blank @:{'terms.glossary.to'} keep @:{'terms.replication.article.host_mention'} existing @:{'terms.glossary.password'}.",
@@ -38,6 +38,13 @@
"title": "@:{'phrases.compat.departments.tab.opening_hours'}"
},
"phone": "@:common.phone",
"pricing": {
"custom_pricing_disabled": "@:{'phrases.compat.departments.pricing.custom_pricing_disabled'}",
"custom_pricing_enabled": "@:{'phrases.compat.departments.pricing.custom_pricing_enabled'}",
"custom_pricing_missing_price": "@:{'phrases.compat.departments.pricing.custom_pricing_missing_price'}",
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
"effective_department_price": "@:{'phrases.compat.departments.pricing.effective_department_price'}"
},
"products": {
"new_product": "@:products.new_product"
},
@@ -194,6 +194,7 @@
"departments": {
"columns": {
"archived": "@:{'phrases.compat.objects.departments.columns.archived'}",
"custom_pricing_only": "@:{'phrases.compat.objects.departments.columns.custom_pricing_only'}",
"dimension": "@:{'phrases.compat.objects.departments.columns.dimension'}",
"economic_department": "@:{'phrases.compat.objects.departments.columns.economic_department'}",
"latitude": "@:{'phrases.compat.objects.departments.columns.latitude'}",
+16
View File
@@ -46,6 +46,14 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -279,6 +287,14 @@
"edit_title": "@:{'terms.glossary.rediger'} @:{'terms.glossary.medarbeider'}",
"name": "Navn",
"email": "E-post",
"phone": "Telefon",
"phone_validation": "Telefonnummeret må være 4-15 sifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.passord'}",
"password_create_help": "@.capitalize:{'terms.glossary.kreves'} @:{'terms.glossary.for'} nye @:{'terms.glossary.medarbeidere'}. Bruk @:{'terms.glossary.minst'} 8 tegn.",
"password_update_help": "La stå tomt @:{'terms.glossary.for'} å beholde nåværende @:{'terms.glossary.passord'}.",
+16
View File
@@ -46,6 +46,14 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -279,6 +287,14 @@
"edit_title": "@:{'terms.glossary.redigera'} @:{'terms.glossary.medarbetare'}",
"name": "Namn",
"email": "E-post",
"phone": "Telefon",
"phone_validation": "Telefonnumret måste vara 4-15 siffror.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.losenord'}",
"password_create_help": "@.capitalize:{'terms.glossary.kravs'} @:{'terms.glossary.for'} nya @:{'terms.glossary.medarbetare'}. Använd @:{'terms.glossary.minst'} 8 tecken.",
"password_update_help": "Lämna tomt @:{'terms.glossary.for'} att behålla nuvarande @:{'terms.glossary.losenord'}.",
+84
View File
@@ -0,0 +1,84 @@
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const DATE_ONLY_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/;
const padDatePart = (value) => String(value).padStart(2, "0");
export const isValidDate = (value) => value instanceof Date && !Number.isNaN(value.getTime());
export const formatLocalDateOnly = (value = new Date()) => {
if (typeof value === "string") {
const directMatch = value.trim().match(DATE_ONLY_PREFIX_PATTERN);
if (directMatch) {
return directMatch[1];
}
}
const date = value instanceof Date ? value : new Date(value);
if (!isValidDate(date)) {
return "";
}
return [
date.getFullYear(),
padDatePart(date.getMonth() + 1),
padDatePart(date.getDate()),
].join("-");
};
export const parseLocalDateOnly = (value) => {
if (value instanceof Date) {
return isValidDate(value)
? new Date(value.getFullYear(), value.getMonth(), value.getDate())
: new Date(Number.NaN);
}
const stringValue = String(value ?? "").trim();
const match = DATE_ONLY_PATTERN.exec(stringValue);
if (match) {
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const parsed = new Date(year, month - 1, day);
if (
parsed.getFullYear() === year
&& parsed.getMonth() === month - 1
&& parsed.getDate() === day
) {
return parsed;
}
return new Date(Number.NaN);
}
const parsed = new Date(stringValue);
return isValidDate(parsed)
? new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
: parsed;
};
export const todayLocalDateOnly = () => formatLocalDateOnly(new Date());
export const yesterdayLocalDateOnly = () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return formatLocalDateOnly(yesterday);
};
export const startOfLocalDate = (value) => {
const date = parseLocalDateOnly(value);
if (!isValidDate(date)) {
return date;
}
date.setHours(0, 0, 0, 0);
return date;
};
export const endOfLocalDate = (value) => {
const date = parseLocalDateOnly(value);
if (!isValidDate(date)) {
return date;
}
date.setHours(23, 59, 59, 999);
return date;
};
@@ -16,6 +16,12 @@ import {
} from "@/services/limitedBackoffice.js";
const ROLE_KEYS = ["viewer", "cashier", "booking_coordinator", "operations_lead", "department_admin"];
const PHONE_COUNTRY_CODES = [
{ value: "45", flag: "🇩🇰", labelKey: "denmark" },
{ value: "46", flag: "🇸🇪", labelKey: "sweden" },
{ value: "47", flag: "🇳🇴", labelKey: "norway" },
{ value: "358", flag: "🇫🇮", labelKey: "finland" },
];
const { t } = useI18n();
const departments = ref([]);
@@ -32,6 +38,8 @@ const showRolePermissions = ref(false);
const form = ref({
display_name: "",
email: "",
phone_country_code: "45",
phone: "",
password: "",
role_key: "viewer",
department_ids: [],
@@ -212,15 +220,41 @@ const rolePermissionGroups = (role) =>
const visibleRoles = computed(() => roles.value.filter((role) => ROLE_KEYS.includes(role.key)));
const isEditing = computed(() => Boolean(editingEmployee.value));
const trimmedEmail = computed(() => String(form.value.email || "").trim());
const isEmailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail.value));
const normalizedPhone = computed(() => String(form.value.phone || "").replace(/\D/g, ""));
const hasPhone = computed(() => normalizedPhone.value.length > 0);
const isPhoneValid = computed(() => !hasPhone.value || /^\d{4,15}$/.test(normalizedPhone.value));
const isPasswordValid = computed(() => isEditing.value || String(form.value.password || "").length >= 8);
const isFormValid = computed(
() =>
String(form.value.display_name || "").trim().length > 0 &&
isEmailValid.value &&
isPhoneValid.value &&
ROLE_KEYS.includes(form.value.role_key) &&
form.value.department_ids.length > 0 &&
isPasswordValid.value
);
const countryCodeLabel = (countryCode) => {
const option = PHONE_COUNTRY_CODES.find((item) => item.value === String(countryCode || ""));
if (!option) {
return "";
}
return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;
};
const formatEmployeePhone = (employee) => {
if (!employee?.phone_country_code || !employee?.phone) {
return "";
}
return `+${employee.phone_country_code} ${employee.phone}`;
};
const sanitizePhone = (event) => {
form.value.phone = String(event?.target?.value || "").replace(/\D/g, "");
};
const loadEmployees = async () => {
const response = await getLimitedBackofficeEmployees({ includeInactive: includeInactive.value });
employees.value = unwrapLimitedBackofficeResponse(response) || [];
@@ -257,6 +291,8 @@ const resetForm = () => {
form.value = {
display_name: "",
email: "",
phone_country_code: "45",
phone: "",
password: "",
role_key: roles.value[0]?.key || "viewer",
department_ids: [],
@@ -269,6 +305,8 @@ const editEmployee = (employee) => {
form.value = {
display_name: employee.display_name || "",
email: employee.email || "",
phone_country_code: employee.phone_country_code ? String(employee.phone_country_code) : "45",
phone: employee.phone ? String(employee.phone) : "",
password: "",
role_key: employee.role?.key || roles.value[0]?.key || "viewer",
department_ids: (employee.departments || []).map((department) => Number(department.id)),
@@ -291,7 +329,9 @@ const setDepartmentChecked = (departmentId, checked) => {
const buildPayload = () => {
const payload = {
display_name: String(form.value.display_name || "").trim(),
email: String(form.value.email || "").trim() || null,
email: trimmedEmail.value,
phone_country_code: hasPhone.value ? Number.parseInt(form.value.phone_country_code, 10) : null,
phone: hasPhone.value ? Number.parseInt(normalizedPhone.value, 10) : null,
role_key: form.value.role_key,
department_ids: form.value.department_ids,
};
@@ -416,12 +456,51 @@ onMounted(() => {
id="limited-employee-email"
v-model="form.email"
class="input"
:class="{ 'is-danger': formAttempted && !isEmailValid }"
type="email"
required
data-testid="limited-employee-email"
/>
</div>
</div>
<div class="field">
<label class="label" for="limited-employee-phone">
{{ t("templates.limited_backoffice.employees.phone") }}
</label>
<div class="limited-employee-phone">
<div class="control">
<div class="select is-fullwidth">
<select
id="limited-employee-phone-country-code"
v-model="form.phone_country_code"
data-testid="limited-employee-phone-country-code"
>
<option v-for="option in PHONE_COUNTRY_CODES" :key="option.value" :value="option.value">
{{ countryCodeLabel(option.value) }}
</option>
</select>
</div>
</div>
<div class="control">
<input
id="limited-employee-phone"
v-model="form.phone"
class="input"
:class="{ 'is-danger': formAttempted && !isPhoneValid }"
type="tel"
inputmode="numeric"
pattern="[0-9]*"
data-testid="limited-employee-phone"
@input="sanitizePhone"
/>
</div>
</div>
<p v-if="formAttempted && !isPhoneValid" class="help is-danger">
{{ t("templates.limited_backoffice.employees.phone_validation") }}
</p>
</div>
<div class="field">
<label class="label" for="limited-employee-password">
{{ t("templates.limited_backoffice.employees.password") }}
@@ -488,15 +567,17 @@ onMounted(() => {
<div class="field">
<label class="label">{{ t("templates.limited_backoffice.employees.departments") }}</label>
<div class="limited-employee-departments" data-testid="limited-employee-departments">
<label v-for="department in departments" :key="department.id" class="checkbox">
<input
type="checkbox"
:checked="departmentChecked(department.id)"
:data-testid="`limited-employee-department-${department.id}`"
@change="setDepartmentChecked(department.id, $event.target.checked)"
/>
<span>{{ department.name }}</span>
</label>
<b-switch
v-for="department in departments"
:key="department.id"
size="is-small"
type="is-link"
:model-value="departmentChecked(department.id)"
:data-testid="`limited-employee-department-${department.id}`"
@update:model-value="setDepartmentChecked(department.id, $event)"
>
{{ department.name }}
</b-switch>
</div>
<p v-if="formAttempted && form.department_ids.length === 0" class="help is-danger">
{{ t("templates.limited_backoffice.employees.department_required") }}
@@ -507,12 +588,12 @@ onMounted(() => {
{{ t("templates.limited_backoffice.employees.validation") }}
</div>
<div class="buttons">
<div class="limited-employee-actions">
<button
type="submit"
class="button is-dark"
class="button is-dark is-fullwidth"
:class="{ 'is-loading': saving }"
:disabled="saving"
:disabled="saving || !isFormValid"
data-testid="limited-employee-save"
>
<span class="icon is-small"><i class="fas fa-save" aria-hidden="true"></i></span>
@@ -521,7 +602,7 @@ onMounted(() => {
<button
v-if="isEditing"
type="button"
class="button is-light"
class="button is-light is-fullwidth"
data-testid="limited-employee-cancel"
@click="resetForm"
>
@@ -582,6 +663,13 @@ onMounted(() => {
<td>
<strong>{{ employee.display_name }}</strong>
<p v-if="employee.email" class="has-text-grey is-size-7">{{ employee.email }}</p>
<p
v-if="formatEmployeePhone(employee)"
class="has-text-grey is-size-7"
:data-testid="`limited-employee-phone-${employee.id}`"
>
{{ formatEmployeePhone(employee) }}
</p>
</td>
<td>
<strong>{{ roleLabel(employee.role) }}</strong>
@@ -733,9 +821,14 @@ onMounted(() => {
gap: 0.5rem;
}
.limited-employee-departments .checkbox {
display: flex;
align-items: center;
.limited-employee-phone {
display: grid;
grid-template-columns: minmax(9.5rem, 0.75fr) minmax(0, 1fr);
gap: 0.75rem;
}
.limited-employee-actions {
display: grid;
gap: 0.5rem;
}
@@ -866,6 +959,10 @@ onMounted(() => {
.limited-employees-header__inactive {
margin-top: 0.5rem;
}
.limited-employee-phone {
grid-template-columns: 1fr;
}
}
@media screen and (max-width: 768px) {
@@ -19,6 +19,7 @@ import DepartmentDashboardDailyReportNavigation from "@/views/dashboards/departm
import DepartmentDashboardDailyReportProductSales from "@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportProductSales.vue";
import DepartmentDashboardDailyReportTodayForm from "@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportTodayForm.vue";
import DepartmentWeather from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentWeather.vue";
import { parseLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
import {
complaints_metric_state,
count_bookings,
@@ -131,7 +132,7 @@ onMounted(() => {
return [...accessibleDepartmentIds.value];
})();
const initialDateFrom = typeof query.dateFrom === "string" ? query.dateFrom : new Date().toISOString().split("T")[0];
const initialDateFrom = typeof query.dateFrom === "string" ? query.dateFrom : todayLocalDateOnly();
const initialDateTo = typeof query.dateTo === "string" ? query.dateTo : initialDateFrom;
initializeDailyReportFilters({
@@ -170,7 +171,7 @@ watch(
);
const formatDateShort = (dateString) => {
const date = new Date(dateString);
const date = parseLocalDateOnly(dateString);
const dayName = date.toLocaleDateString("da-DK", { weekday: "long" });
const dateValue = date.toLocaleDateString("da-DK", { day: "2-digit", month: "2-digit", year: "2-digit" });
return `${dayName} d. ${dateValue}`;
@@ -178,7 +179,7 @@ const formatDateShort = (dateString) => {
const formatDateRangeSubtitle = (startDateString, endDateString) => {
const dateOptions = { day: "2-digit", month: "2-digit", year: "2-digit" };
return `${new Date(startDateString).toLocaleDateString("da-DK", dateOptions)} - ${new Date(endDateString).toLocaleDateString("da-DK", dateOptions)}`;
return `${parseLocalDateOnly(startDateString).toLocaleDateString("da-DK", dateOptions)} - ${parseLocalDateOnly(endDateString).toLocaleDateString("da-DK", dateOptions)}`;
};
const formatSubtitle = (subtitle) => {
@@ -2,18 +2,16 @@
import { computed, ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { createEmptyOutsideHours, normalizeOutsideHours } from "@/views/dashboards/departmentDashboard/modules/daily-report/outsideHours.js";
import { todayLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
const GENERATE_UNIQUE_ID = () => Math.random().toString(36).slice(2, 11);
const DATE_TODAY = () => {
const date = new Date();
return date.toISOString().split("T")[0];
return todayLocalDateOnly();
};
const DATE_YESTERDAY = () => {
const date = new Date();
date.setDate(date.getDate() - 1);
return date.toISOString().split("T")[0];
return yesterdayLocalDateOnly();
};
const date_shortcuts = ref({
@@ -17,6 +17,7 @@ import {
isLoading as departmentsStoreLoading,
} from "@/components/pagination/departmentTabs.vue";
import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
/**
* Props:
@@ -141,14 +142,7 @@ const toggleAllDepartments = () => {
};
const formatDateSelectionValue = (date) => {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "";
}
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}`;
return formatLocalDateOnly(date);
};
const onDateSelectionChange = (startDate, endDate) => {
@@ -161,7 +155,7 @@ const onDateSelectionChange = (startDate, endDate) => {
<div class="column is-12" data-testid="daily-report-date-controls">
<DatePeriodSelector
:on-selection-change="onDateSelectionChange"
:selection="{ startDate: new Date(selected_date), endDate: new Date(selected_date_to) }"
:selection="{ startDate: parseLocalDateOnly(selected_date), endDate: parseLocalDateOnly(selected_date_to) }"
:visibility="{
showDailySelector: true,
showWeeklySelector: true,
@@ -5,6 +5,7 @@ import { parseError, getError, clearErrors} from "@/components/request/HandleGlo
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { selected_date, onChangeCall, selected_department_id} from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
import Swal from "sweetalert2";
import { parseLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
const props = defineProps({
department_id: Number,
@@ -23,7 +24,7 @@ const created_at = ref(null);
const id = ref(null);
const message = ref('');
const yesterday = ref({
date: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split('T')[0],
date: yesterdayLocalDateOnly(),
water_usage: 0,
water_usage_morning: 0,
notes: null,
@@ -104,7 +105,7 @@ const getDailyReport = async () => {
// Check if the date is today
const created_at_val = new Date(response.data.data.created_at);
const today = new Date();
if (created_at_val.toDateString() === (selected_date.value ? new Date(selected_date.value) : today).toDateString()) {
if (created_at_val.toDateString() === (selected_date.value ? parseLocalDateOnly(selected_date.value) : today).toDateString()) {
// The daily report is today
// So we can set the values
water_usage.value = parseInt(response.data.data.water_usage);
@@ -212,4 +213,4 @@ onChangeCall(() => {
<style scoped>
</style>
</style>
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { timeBookingsNewScheduler } from '@/views/dashboards/departmentDashboard/modules/time-bookings/book/TimeBookingsNewScheduler.vue';
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const date = ref();
// Set the initial date to the current date YYYY-MM-DD format
const formatDate = (date_input) => {
return date_input.toISOString().split('T')[0];
return formatLocalDateOnly(date_input);
};
date.value = formatDate(new Date());
@@ -15,7 +16,7 @@ watch(timeBookingsNewScheduler.date.selected, (newDate) => {
watch(date, (newDate) => {
// Update the selected date in the scheduler when the date input changes
timeBookingsNewScheduler.date.selected.value = new Date(newDate);
timeBookingsNewScheduler.date.selected.value = parseLocalDateOnly(newDate);
});
</script>
@@ -63,4 +64,4 @@ watch(date, (newDate) => {
<style scoped>
</style>
</style>
@@ -1,6 +1,7 @@
<script setup>
import { ref, watch } from 'vue';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const props = defineProps({
department_id: {
@@ -68,8 +69,7 @@ const getBookings = async () => {
};
const getDate = () => {
const date = new Date();
return date.toISOString().split('T')[0];
return todayLocalDateOnly();
};
// Get the department
@@ -2,6 +2,7 @@
import { selected_date, selected_date_to, selectDate as selectDailyReportDate } from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
// Emits for parents that want to react to date changes (non-breaking if unused)
const emit = defineEmits(['date-change']);
@@ -16,12 +17,12 @@ const selectDate = (startIso, endIso) => {
<template>
<DatePeriodSelector
:on-selection-change="(startDate, endDate) => selectDate(startDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0])"
:selection="{ startDate: new Date(selected_date), endDate: new Date(selected_date_to) }"
:on-selection-change="(startDate, endDate) => selectDate(formatLocalDateOnly(startDate), formatLocalDateOnly(endDate))"
:selection="{ startDate: parseLocalDateOnly(selected_date), endDate: parseLocalDateOnly(selected_date_to) }"
:visibility="{ showDailySelector: true, showWeeklySelector: true, showMultipleMonthWarning: false, showUpdateButton: false }"
:reverse-level-order="true"
@update:selection="(newSelection) => {
selectDate(newSelection.startDate.toISOString().split('T')[0], newSelection.endDate.toISOString().split('T')[0]);
selectDate(formatLocalDateOnly(newSelection.startDate), formatLocalDateOnly(newSelection.endDate));
}"
/>
</template>
@@ -1,5 +1,6 @@
<script lang="ts">
import {defineComponent, ref, watch} from 'vue';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
type fetch = {
@@ -11,8 +12,8 @@ type fetch = {
};
/** Define the variables */
export const show_this_week = ref(false);
export const selected_date = ref(new Date().toISOString().split('T')[0]);
export const selected_date_to = ref(new Date().toISOString().split('T')[0]);
export const selected_date = ref(todayLocalDateOnly());
export const selected_date_to = ref(todayLocalDateOnly());
export const last_fetch_id = ref(0);
export const fetches = ref<fetch[]>([]);
/** Define the functions */
@@ -53,4 +54,4 @@ watch([selected_date, selected_date_to], () => {
export default defineComponent({
name: 'DepartmentsOverviewObject'
});
</script>
</script>
@@ -23,6 +23,7 @@ import {
} from "../../imports/InvoicingBillingPeriodImportPaging.js";
import { buildPossibleDuplicateGroups } from "../../utils/possibleDuplicateGroups.js";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
const period_result = ref<any>(null);
const route = useRoute();
@@ -416,7 +417,7 @@ const loadSelfWashCounts = async ({ forceRefresh = false } = {}) => {
'GET',
{
page: 1,
limit: 500,
limit: SELFWASH_PERIOD_ALL_LIMIT,
filters,
order: 'StartTime:desc',
},
@@ -4,7 +4,7 @@ export const PERIOD_CACHE_TTL_MS = 10 * 60 * 1000;
export const PERIOD_LIMIT_OPTIONS = [10, 25, 50, 100, 200, 500, "all"];
const CACHE_PREFIX = "invoicing-period-page:v2:";
const SELF_WASH_COUNTS_CACHE_PREFIX = "invoicing-period-self-wash-counts:";
const SELF_WASH_COUNTS_CACHE_PREFIX = "invoicing-period-self-wash-counts:v2:";
const DEFAULT_LIMIT = 100;
const ALL_LIMIT = "all";
@@ -3,7 +3,15 @@ import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
import { setDepartment, department, departmentId, getDepartmentPrice, editDepartmentPrice } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import {
CUSTOM_PRICING_MISSING_PRICE,
setDepartment,
getDepartmentPrices,
getExplicitDepartmentPrice,
editDepartmentPrice,
isCustomPricingOnly,
updateCustomPricingOnly,
} from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { useRouter } from 'vue-router'
import { ref } from 'vue';
import { getProducts } from "@/components/shop/Products.vue";
@@ -11,14 +19,40 @@ import { getProducts } from "@/components/shop/Products.vue";
// Get the department from the route
const router = useRouter()
setDepartment(router.currentRoute.value.params.departmentId);
getDepartmentPrices();
// Set the products
const products = ref([]);
const isUpdatingCustomPricingOnly = ref(false);
// Get the products
getProducts().then((response) => {
products.value = response.data.data;
});
const getDepartmentPriceDisplay = (product) => {
const explicitPrice = getExplicitDepartmentPrice(product);
if (explicitPrice !== null) {
return explicitPrice;
}
return isCustomPricingOnly() ? CUSTOM_PRICING_MISSING_PRICE : '-';
};
const isMissingCustomPrice = (product) => {
return isCustomPricingOnly() && getExplicitDepartmentPrice(product) === null;
};
const toggleCustomPricingOnly = async (event) => {
const enabled = event.target.checked;
isUpdatingCustomPricingOnly.value = true;
try {
await updateCustomPricingOnly(enabled);
} catch {
event.target.checked = isCustomPricingOnly();
} finally {
isUpdatingCustomPricingOnly.value = false;
}
};
</script>
<template>
@@ -28,12 +62,38 @@ getProducts().then((response) => {
<PageTitle title="Department" subtitle="Department pricing" />
</template>
<div>
<section class="department-pricing-settings" data-testid="department-custom-pricing-settings">
<div>
<h2 class="title is-5 mb-1">{{ $t('departments.pricing.custom_pricing_only') }}</h2>
<p class="is-size-7 has-text-grey">
{{ $t('departments.pricing.custom_pricing_missing_price') }}
</p>
</div>
<div class="department-pricing-toggle">
<input
id="department-custom-pricing-only"
class="switch is-rounded is-info"
type="checkbox"
:checked="isCustomPricingOnly()"
:disabled="isUpdatingCustomPricingOnly"
data-testid="department-custom-pricing-only-toggle"
@change="toggleCustomPricingOnly"
/>
<label for="department-custom-pricing-only">
{{
isCustomPricingOnly()
? $t('departments.pricing.custom_pricing_enabled')
: $t('departments.pricing.custom_pricing_disabled')
}}
</label>
</div>
</section>
<table class="is-fullwidth table table-striped is-hoverable is-bordered">
<thead>
<tr>
<th>{{ $t('common.product') }}</th>
<th>{{ $t('tables.common.default_price') }}</th>
<th>{{ $t('tables.common.department_price') }}</th>
<th>{{ $t('departments.pricing.effective_department_price') }}</th>
</tr>
</thead>
<tbody>
@@ -43,24 +103,45 @@ getProducts().then((response) => {
<td
@click="editDepartmentPrice(product)"
class="is-clickable"
>{{ getDepartmentPrice(product) === product.price ? '-' : getDepartmentPrice(product) }}
:class="{ 'has-text-danger has-text-weight-semibold': isMissingCustomPrice(product) }"
:data-testid="`department-price-cell-${product.id}`"
>{{ getDepartmentPriceDisplay(product) }}
<i class="is-pulled-right fas fa-edit"></i>
</td>
</tr>
</tbody>
</table>
Department prices...
<code>{{ departmentId }}</code>
<code>{{ department.id }}</code>
<code>{{ department.name }}</code>
<code>{{ department.description }}</code>
<code>{{ department.created_at }}</code>
<code>{{ department.updated_at }}</code>
</div>
</DepartmentSubPageWrapper>
</RestrictedPageWrapper>
</template>
<style scoped>
.department-pricing-settings {
align-items: center;
border: 1px solid #dbdbdb;
border-radius: 6px;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
padding: 1rem;
}
</style>
.department-pricing-toggle {
align-items: center;
display: flex;
min-width: 16rem;
}
@media (max-width: 768px) {
.department-pricing-settings {
align-items: flex-start;
flex-direction: column;
}
.department-pricing-toggle {
min-width: 0;
}
}
</style>
@@ -4,12 +4,14 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
import Swal from "sweetalert2";
// Define the department id
export const departmentId = ref(0);
export const CUSTOM_PRICING_MISSING_PRICE = 999999;
const default_department = {
id: null,
name: null,
description: null,
branding: null,
custom_pricing_only: false,
created_at: null,
updated_at: null,
prices: []
@@ -34,6 +36,7 @@ export const department = {
id: ref(''),
name: ref(''),
description: ref(''),
custom_pricing_only: ref(false),
created_at: ref(''),
updated_at: ref(''),
prices: ref([])
@@ -49,6 +52,13 @@ const isDepartmentPricesLoaded = ref(false);
// Set the department id
export const setDepartment = (id) => {
departmentId.value = id;
isDepartmentPricesLoaded.value = false;
department.prices.value = [];
department.custom_pricing_only.value = false;
departmentAdvanced.value = {
...default_department,
...default_department_functions
};
// Load the department data
return getDepartmentData();
}
@@ -60,11 +70,13 @@ export const getDepartmentData = async () => {
department.id.value = response.data.data.id;
department.name.value = response.data.data.name;
department.description.value = response.data.data.description;
department.custom_pricing_only.value = Boolean(response.data.data.custom_pricing_only);
department.created_at.value = response.data.data.created_at;
department.updated_at.value = response.data.data.updated_at;
departmentAdvanced.value = {
...default_department,
...response.data.data,
custom_pricing_only: Boolean(response.data.data.custom_pricing_only),
...default_department_functions
};
})
@@ -88,17 +100,42 @@ export const getDepartmentPrice = (product) => {
getDepartmentPrices();
}
if (isDepartmentPricesLoaded.value) {
const price = department.prices.value.find((price) => price.product_id === product.id);
return price ? price.price : product.price;
const price = getExplicitDepartmentPrice(product);
if (price !== null) {
return price;
}
return isCustomPricingOnly() ? CUSTOM_PRICING_MISSING_PRICE : product.price;
}
return product.price;
}
export const getExplicitDepartmentPrice = (product) => {
const price = department.prices.value.find((price) => price.product_id === product.id);
return price ? price.price : null;
}
export const isCustomPricingOnly = () => Boolean(departmentAdvanced.value.custom_pricing_only || department.custom_pricing_only.value);
export const updateCustomPricingOnly = async (enabled) => {
return authenticatedRequest(`/departments`, "PUT", {
id: departmentId.value,
custom_pricing_only: Boolean(enabled)
}).then(async () => {
department.custom_pricing_only.value = Boolean(enabled);
departmentAdvanced.value = {
...departmentAdvanced.value,
custom_pricing_only: Boolean(enabled)
};
await getDepartmentData();
});
}
export const editDepartmentPrice = (product) => {
const explicitPrice = getExplicitDepartmentPrice(product);
Swal.fire({
title: product.name + ' ( product: ' + product.id + ' )',
input: 'number',
inputValue: getDepartmentPrice(product),
inputValue: explicitPrice === null ? '' : explicitPrice,
inputLabel: 'Price',
inputAttributes: {
autocapitalize: 'off'
@@ -13,6 +13,7 @@ import StatisticsDepartmentGoal
from "@/views/dashboards/superUserDashboard/statistics/displays/overview/StatisticsDepartmentGoal.vue";
import {departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
import { useI18n } from 'vue-i18n';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n();
@@ -70,7 +71,8 @@ getDepartments()
// Get the statistics
const getStatistics = async () => {
// Get the statistics
const response = await authenticatedRequest('/statistics/income/departments?start_date=' + (new Date()).toISOString().split('T')[0] + '&end_date=' + (new Date()).toISOString().split('T')[0])
const today = todayLocalDateOnly();
const response = await authenticatedRequest('/statistics/income/departments?start_date=' + today + '&end_date=' + today)
// Get the sent data
statistics_department_today.value = response.data.data
// Log the data
@@ -131,4 +133,4 @@ getStatistics()
<style scoped>
</style>
</style>
@@ -4,6 +4,7 @@ import {ref} from 'vue';
import { Bar } from 'vue-chartjs';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale } from 'chart.js'
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
@@ -16,7 +17,7 @@ const chartOptions = {
// Shortcut to statistics
// Set the time for the statistics to today
SessionUser.adminUser.statistics.set_time((new Date()).toISOString().split('T')[0], (new Date()).toISOString().split('T')[0])
SessionUser.adminUser.statistics.set_time(todayLocalDateOnly(), todayLocalDateOnly())
@@ -33,4 +34,4 @@ SessionUser.adminUser.statistics.set_time((new Date()).toISOString().split('T')[
}"
/>
</div>
</template>
</template>
@@ -16,6 +16,7 @@ const json = (body: unknown, status = 200) => ({
contentType: "application/json",
body: JSON.stringify(body),
});
const pageReadyTimeout = process.env.CI ? 30_000 : 15_000;
const setupNotificationSmsApi = async (page: Page) => {
let listRequestCount = 0;
@@ -115,9 +116,10 @@ test("@smoke department notification SMS active toggle and delete refresh the ta
const notificationSmsApi = await setupNotificationSmsApi(page);
await page.goto("/admin/1/modules/notifications", { waitUntil: "domcontentloaded" });
await expect.poll(() => notificationSmsApi.listRequestCount, { timeout: pageReadyTimeout }).toBeGreaterThan(0);
const row = page.locator("tr", { hasText: "Dispatch line" });
await expect(row).toBeVisible();
await expect(row).toBeVisible({ timeout: pageReadyTimeout });
const enabledToggle = page.getByTestId("department-notification-sms-enabled-toggle-501");
await expect(enabledToggle).toBeChecked();
+45 -20
View File
@@ -17,6 +17,8 @@ const POS_PERMISSIONS = [
const SUPERUSER_POS_PERMISSIONS = [...POS_PERMISSIONS, "superuser"];
const POS_BOOT_URL = "/admin/12/modules/pos?step=1";
const POS_BOOT_READY_TIMEOUT = 20_000;
const POS_ORDER_READY_TIMEOUT = 20_000;
const DRAFT_TRANSACTION_CUSTOMER_ID = 44556677;
const DRAFT_TRANSACTION_CUSTOMER_NAME = "(TEST) Draft Transaction Customer";
@@ -49,6 +51,22 @@ function getPosBootStep(page: Page) {
return page.locator('[data-testid="pos-step-1"]:visible, [data-testid="pos-mobile-step-1"]:visible').first();
}
function getPosOrderReadyTarget(page: Page) {
return page
.locator(
[
'[data-testid="pos-order-card"]:visible',
'[data-testid="pos-order-main"]:visible',
'[data-testid="pos-order-detail"]:visible',
].join(", ")
)
.first();
}
async function expectPosOrderReady(page: Page, timeout = POS_ORDER_READY_TIMEOUT) {
await expect(getPosOrderReadyTarget(page)).toBeVisible({ timeout });
}
async function navigateToPosBootPage(page: Page, { waitForSession = false }: { waitForSession?: boolean } = {}) {
let lastError: unknown;
@@ -64,9 +82,8 @@ async function navigateToPosBootPage(page: Page, { waitForSession = false }: { w
try {
await page.goto(POS_BOOT_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
await Promise.race([sessionRequest, getPosBootStep(page).waitFor({ state: "visible", timeout: 10_000 })]);
await expect(getPosBootStep(page)).toBeVisible({ timeout: 10_000 });
await sessionRequest;
await expect(getPosBootStep(page)).toBeVisible({ timeout: POS_BOOT_READY_TIMEOUT });
return;
} catch (error) {
lastError = error;
@@ -162,19 +179,24 @@ async function waitForSwalToClose(page: Page) {
async function openOrderSettings(page: Page, orderId: number) {
await page.goto(`/admin/12/modules/pos/orders/${orderId}`);
await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 20_000 });
await expectPosOrderReady(page);
await clickVisibleTestId(page, "pos-order-tab-settings");
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
}
async function revisitCurrentPage(page: Page) {
await page.goto(page.url(), { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 20_000 });
if (new URL(page.url()).pathname.includes("/modules/pos/orders/")) {
await expectPosOrderReady(page);
return;
}
await expect(getPosBootStep(page)).toBeVisible({ timeout: POS_BOOT_READY_TIMEOUT });
}
async function reloadOrderSettings(page: Page, orderId: number) {
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await page.goto(`/admin/12/modules/pos/orders/${orderId}`, { waitUntil: "domcontentloaded", timeout: 30_000 });
await expectPosOrderReady(page);
await clickVisibleTestId(page, "pos-order-tab-settings");
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
}
@@ -313,17 +335,18 @@ async function changeOrderInvoiceCollection(page: Page, closedAt: string) {
async function openOrderDetail(page: Page, orderId = 54518) {
const orderUrl = `/admin/12/modules/pos/orders/${orderId}`;
for (let attempt = 0; attempt < 2; attempt += 1) {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await page.goto(orderUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 10_000 });
await expectPosOrderReady(page);
return;
} catch (error) {
if (attempt === 1) {
if (attempt === 2) {
throw error;
}
await navigateToPosBootPage(page);
await page.goto("about:blank").catch(() => {});
await delay(250);
}
}
}
@@ -989,7 +1012,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
expect(Math.abs((beforeRailBox?.width ?? 0) - (restoredRailBox?.width ?? 0))).toBeLessThanOrEqual(20);
await page.goto("/admin/12/modules/pos/orders/54518", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
await expectOrderTotal(page, 1397);
});
@@ -1107,7 +1130,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(noteInput).toBeFocused();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(referenceValue);
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(poValue);
await expect(page.getByTestId("pos-order-metadata-note")).toContainText(noteValue);
@@ -1160,7 +1183,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
});
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-customer-wishes-reference")).toContainText("+ Tilføj");
await expect(page.getByTestId("pos-order-customer-wishes-po")).toContainText("+ Tilføj");
});
@@ -1221,7 +1244,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(reg3Input).toHaveValue("NO9012", { timeout: 10000 });
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-registration-1")).toContainText("XY1234");
await expect(page.getByTestId("pos-order-registration-2")).toContainText("TR5678");
await expect(page.getByTestId("pos-order-registration-3")).toContainText("NO9012");
@@ -1249,7 +1272,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expectOrderTotal(page, 2123);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-item-note-trigger-9101")).toBeVisible();
await expect(page.getByTestId("pos-order-item-reference-trigger-9101")).toBeVisible();
await expect(page.getByTestId("pos-order-item-price-9101")).toContainText("1400");
@@ -1274,7 +1297,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expectOrderTotal(page, 1423);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-item-note-trigger-9102")).toBeVisible();
await expect(page.getByTestId("pos-order-item-price-9102")).toContainText("450");
await expectOrderTotal(page, 1423);
@@ -1324,7 +1347,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expectOrderTotal(page, 0);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-empty-state")).toBeVisible();
await expectOrderTotal(page, 0);
});
@@ -1347,7 +1370,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expectOrderTotal(page, 2021);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-item-edit-9200").first()).toBeVisible();
await expectOrderTotal(page, 2021);
});
@@ -1432,6 +1455,8 @@ test.describe("Admin POS Orders - desktop settings", () => {
});
test("persists scalar metadata edits across settings and details tabs", async ({ page }) => {
test.setTimeout(90_000);
const { orderId } = await createDisposableOrder(page);
const submittedValues = {
reference: "UPDATED-REF-2026",
@@ -2964,7 +2989,7 @@ test.describe("Admin POS Orders - mobile smoke", () => {
test("opens the settings tab and renders the new controls", async ({ page }) => {
await page.goto("/admin/12/modules/pos/orders/54518");
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await clickVisibleTestId(page, "pos-order-tab-settings");
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
@@ -3093,7 +3118,7 @@ test.describe("Admin POS Orders - mobile smoke", () => {
expect(Math.abs((restoredMainBox?.width ?? 0) - (beforeMainBox?.width ?? 0))).toBeLessThanOrEqual(20);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
await expectOrderTotal(page, 1397);
});
+57 -5
View File
@@ -109,6 +109,58 @@ async function acceleratePageTimers(page, timerScale = 0.01) {
}, timerScale);
}
async function dismissUnexpectedSweetAlert(page) {
const overlays = page.locator(".swal2-container.swal2-backdrop-show");
for (let attempt = 0; attempt < 3; attempt += 1) {
const overlay = overlays.first();
if (!(await overlay.isVisible().catch(() => false))) {
return;
}
let dismissed = false;
for (const selector of [".swal2-close", ".swal2-cancel", ".swal2-deny", ".swal2-confirm"]) {
const action = overlay.locator(selector).first();
if (await action.isVisible().catch(() => false)) {
await action.click({ force: true });
dismissed = true;
break;
}
}
if (!dismissed) {
await page.keyboard.press("Escape");
}
await expect(overlays)
.toHaveCount(0, { timeout: 5_000 })
.catch(() => {});
}
}
async function generateGatewayInstaller(page) {
const button = page.getByTestId("gateway-installer-generate");
await expect(button).toBeVisible({ timeout: 15_000 });
await expect(button).toBeEnabled({ timeout: 15_000 });
let lastError;
for (let attempt = 0; attempt < 3; attempt += 1) {
await dismissUnexpectedSweetAlert(page);
try {
await button.click({ timeout: 15_000 });
return;
} catch (error) {
lastError = error;
if (!/swal2-container|intercepts pointer events/i.test(error?.message || "")) {
throw error;
}
}
}
throw lastError;
}
test.describe("Edge gateway management smoke", () => {
test.describe.configure({ mode: "serial" });
@@ -193,7 +245,7 @@ test.describe("Edge gateway management smoke", () => {
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Copy Test Pi");
await page.getByTestId("gateway-installer-generate").click();
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/);
await page.getByTestId("gateway-installer-copy").click();
@@ -246,7 +298,7 @@ test.describe("Edge gateway management smoke", () => {
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Canary Pi");
await page.getByTestId("gateway-installer-generate").click();
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running");
@@ -274,7 +326,7 @@ test.describe("Edge gateway management smoke", () => {
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("CPH Edge 01");
await page.getByTestId("gateway-installer-generate").click();
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running");
@@ -318,7 +370,7 @@ test.describe("Edge gateway management smoke", () => {
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Broken Pi");
await page.getByTestId("gateway-installer-generate").click();
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
await expect
@@ -382,7 +434,7 @@ test.describe("Edge gateway management smoke", () => {
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Canary Pi");
await page.getByTestId("gateway-installer-generate").click();
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/703\/overview$/, { timeout: 20_000 });
+8 -3
View File
@@ -1132,7 +1132,6 @@ test.describe("Invoicing period tab", () => {
};
await openPeriodView(page);
await setEntireMarchPeriod(page);
await page.route("**/modules/xlvask/services/usage/orders**", async (route) => {
const path = new URL(route.request().url()).pathname;
@@ -1242,8 +1241,10 @@ test.describe("Invoicing period tab", () => {
);
});
await setEntireMarchPeriod(page);
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (0/1)");
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (0/2)");
await expect(page.getByTestId("invoicing-period-view-selector-progress-self_wash")).toBeVisible();
await page.getByTestId("invoicing-period-view-selector-self_wash").click();
@@ -1273,6 +1274,7 @@ test.describe("Invoicing period tab", () => {
await page.getByTestId("xlvask-automation-accept-8101").click();
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Accepteret #7001");
expect(automationAcceptRequests).toEqual([{ suggestion_id: 7101 }]);
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (1/2)");
await expect.poll(() => usageOrderRequests.length).toBeGreaterThan(0);
expect(usageOrderRequests.some((request) => request.limit === "10000")).toBe(true);
@@ -2667,7 +2669,10 @@ test.describe("Invoicing period tab", () => {
await page.goto("/superuser/invoices?activeTab=period");
await expect(page).toHaveURL(/activeTab=period/);
await expect.poll(() => requestCount).toBeGreaterThan(0);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
timeout: periodRouteReadyTimeout,
});
await expect.poll(() => requestCount, { timeout: periodRouteReadyTimeout }).toBeGreaterThan(0);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
+150 -2
View File
@@ -213,6 +213,8 @@ const employeesPayload = [
customer_number: 900000501,
display_name: "Casey Clerk",
email: "casey@example.com",
phone_country_code: 45,
phone: 12345678,
active: true,
role: { key: "cashier", label: "Cashier", description: "Can sell." },
departments: [{ id: 1, name: "Assigned Depot" }],
@@ -228,10 +230,17 @@ async function seedLimitedBackofficeSession(page, token = "limited-backoffice-to
await seedAuthenticatedState(page, token);
}
async function mockLimitedBackofficeApi(page, authSessionData = sessionData) {
const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value));
async function mockLimitedBackofficeApi(page, authSessionData = sessionData, options: any = {}) {
const calls: string[] = [];
const forbiddenCalls: string[] = [];
const priceUpdateCalls: unknown[] = [];
const employeeCreateCalls: unknown[] = [];
const employeeUpdateCalls: unknown[] = [];
const currentEmployees = {
value: cloneJson(options.employeesPayload ?? employeesPayload),
};
await page.route(API_HOST, async (route) => {
const request = route.request();
@@ -298,7 +307,56 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData) {
}
if (pathname.endsWith("/limited-backoffice/employees") && method === "GET") {
await route.fulfill(json({ data: employeesPayload }));
await route.fulfill(json({ data: currentEmployees.value }));
return;
}
if (pathname.endsWith("/limited-backoffice/employees") && method === "POST") {
const body = request.postDataJSON?.() || {};
employeeCreateCalls.push(body);
const role = rolesPayload.find((item) => item.key === body.role_key) || rolesPayload[0];
const created = {
id: 900 + currentEmployees.value.length + 1,
customer_number: 900001000 + currentEmployees.value.length + 1,
display_name: body.display_name,
email: body.email,
phone_country_code: body.phone_country_code ?? null,
phone: body.phone ?? null,
active: true,
role,
departments: assignedDepartments.filter((department) => body.department_ids?.includes(department.id)),
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
};
currentEmployees.value.unshift(created);
await route.fulfill(json({ data: created }));
return;
}
const employeeMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)$/);
if (employeeMatch && method === "PUT") {
const body = request.postDataJSON?.() || {};
employeeUpdateCalls.push(body);
const employeeId = Number(employeeMatch[1]);
const target = currentEmployees.value.find((employee) => Number(employee.id) === employeeId);
if (!target) {
await route.fulfill(json({ message: "Employee not found" }, 404));
return;
}
const role = rolesPayload.find((item) => item.key === body.role_key) || target.role;
Object.assign(target, {
display_name: body.display_name ?? target.display_name,
email: body.email ?? target.email,
phone_country_code: body.phone_country_code ?? null,
phone: body.phone ?? null,
role,
departments: Array.isArray(body.department_ids)
? assignedDepartments.filter((department) => body.department_ids.includes(department.id))
: target.departments,
updated_at: "2026-01-01 01:00:00",
});
await route.fulfill(json({ data: target }));
return;
}
@@ -309,6 +367,8 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData) {
calls,
forbiddenCalls,
priceUpdateCalls,
employeeCreateCalls,
employeeUpdateCalls,
};
}
@@ -378,6 +438,7 @@ test.describe("Limited backoffice", () => {
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Clerk");
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+45 12345678");
await expect(page.locator("#limited-employee-role option")).toHaveCount(5);
await expect(page.getByTestId("limited-employee-role")).not.toContainText("Superuser");
await expect(page.locator("body")).not.toContainText("department_access_1");
@@ -436,6 +497,93 @@ test.describe("Limited backoffice", () => {
await expect(page.getByTestId("limited-role-permissions-modal")).toHaveCount(0);
});
test("validates employee fields and submits optional phone details", async ({ page }) => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
await expect(page.getByTestId("limited-employee-save")).toHaveClass(/is-fullwidth/);
await expect(page.getByTestId("limited-employee-departments").locator(".switch")).toHaveCount(1);
await expect(page.getByTestId("limited-employee-phone-country-code")).toContainText("+45");
await page.getByTestId("limited-employee-name").fill("No Phone Worker");
await page.getByTestId("limited-employee-email").fill("no-phone@example.com");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
await page.getByTestId("limited-employee-department-1").click();
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
await expect.poll(() => api.employeeCreateCalls.length).toBe(1);
expect(api.employeeCreateCalls[0]).toEqual({
display_name: "No Phone Worker",
email: "no-phone@example.com",
phone_country_code: null,
phone: null,
password: "Secret123!",
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-902")).toContainText("No Phone Worker");
await page.getByTestId("limited-employee-name").fill("Phone Worker");
await page.getByTestId("limited-employee-email").fill("phone@example.com");
await page.getByTestId("limited-employee-phone-country-code").selectOption("358");
await page.getByTestId("limited-employee-phone").fill("87654321");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await page.getByTestId("limited-employee-department-1").click();
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
await expect.poll(() => api.employeeCreateCalls.length).toBe(2);
expect(api.employeeCreateCalls[1]).toEqual({
display_name: "Phone Worker",
email: "phone@example.com",
phone_country_code: 358,
phone: 87654321,
password: "Secret123!",
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-903")).toContainText("Phone Worker");
await expect(page.getByTestId("limited-employee-phone-903")).toHaveText("+358 87654321");
});
test("edits employee contact details without requiring a new password", async ({ page }) => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await expect(page.getByTestId("limited-employee-row-501")).toBeVisible();
await page.getByTestId("limited-employee-edit-501").click();
await expect(page.getByTestId("limited-employee-password")).toHaveValue("");
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-name").fill("Casey Lead");
await page.getByTestId("limited-employee-email").fill("casey.lead@example.com");
await page.getByTestId("limited-employee-phone-country-code").selectOption("358");
await page.getByTestId("limited-employee-phone").fill("87654321");
await page.getByTestId("limited-employee-save").click();
await expect.poll(() => api.employeeUpdateCalls.length).toBe(1);
expect(api.employeeUpdateCalls[0]).toEqual({
display_name: "Casey Lead",
email: "casey.lead@example.com",
phone_country_code: 358,
phone: 87654321,
role_key: "cashier",
department_ids: [1],
});
expect(api.employeeUpdateCalls[0]).not.toHaveProperty("password");
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Lead");
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+358 87654321");
});
test("does not render data for a department outside the manager scope", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
+44 -8
View File
@@ -124,6 +124,39 @@ async function seedSavedProgress(page, overrides = {}) {
await page.addInitScript((savedProgress) => {
window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress));
}, payload);
try {
await page.evaluate((savedProgress) => {
window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress));
}, payload);
} catch {}
}
async function expectFinishingOrCompletedWash(page) {
const finishing = page.getByTestId("self-serve-finishing-wash");
const completed = page.getByTestId("self-serve-completed-step");
await expect
.poll(
async () => {
if (await finishing.isVisible().catch(() => false)) {
return "finishing";
}
if (await completed.isVisible().catch(() => false)) {
return "completed";
}
return "pending";
},
{
message: "expected the wash to show the finishing state or complete",
timeout: 15_000,
}
)
.toMatch(/^(finishing|completed)$/);
if (await finishing.isVisible().catch(() => false)) {
await expect(finishing).toContainText("Afslutter vask, porten åbnes automatisk");
}
}
function captureSelfServeGatewayRequests(page) {
@@ -276,9 +309,7 @@ test.describe("Self-serve wash", () => {
const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP");
const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE");
await page.getByTestId("self-serve-nav-complete").click();
await expect(page.getByTestId("self-serve-finishing-wash")).toContainText(
"Afslutter vask, porten åbnes automatisk"
);
await expectFinishingOrCompletedWash(page);
const stopCommandRequest = await stopCommandRequestPromise;
const exitGateCommandRequest = await exitGateCommandRequestPromise;
expect(stopCommandRequest.postDataJSON?.()).toMatchObject({
@@ -660,11 +691,18 @@ test.describe("Self-serve wash", () => {
});
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled();
await page.getByTestId("self-serve-question-21-yes").click();
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
await page.getByTestId("self-serve-nav-confirm").click();
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("radio", { name: /Maskine/i })).toBeDisabled();
await expect(page.getByTestId("self-serve-lane-option-7")).toContainText("Tilgængelig");
await expect(page.getByTestId("self-serve-lane-option-8")).toContainText("Vaskebanen er ikke tilgængelig");
await expect(page.getByRole("radio", { name: /Maskine/i })).toBeEnabled();
await expect(page.getByTestId("self-serve-machine-unavailable-guidance")).toHaveCount(0);
await expect(page.locator("body")).not.toContainText(removedMachineUnavailableGuidanceText);
await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled();
await page.getByTestId("self-serve-lane-option-7").click();
await page.getByTestId("self-serve-wash-type-manual").click();
@@ -1147,9 +1185,7 @@ test.describe("Self-serve wash", () => {
const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP");
const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE");
await page.getByTestId("self-serve-nav-complete").click();
await expect(page.getByTestId("self-serve-finishing-wash")).toContainText(
"Afslutter vask, porten åbnes automatisk"
);
await expectFinishingOrCompletedWash(page);
const stopCommandRequest = await stopCommandRequestPromise;
const exitGateCommandRequest = await exitGateCommandRequestPromise;
@@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
const json = (body: unknown, status = 200) => ({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
const now = "2026-07-06 10:00:00";
const buildProduct = (product: Record<string, unknown>) => ({
description: "E2E product",
subscription_allowed: true,
category: 1,
piktogram: "truck",
economic_product_id: 0,
apply_category_discount: false,
requires_note: false,
created_at: now,
updated_at: now,
addons: [],
is_wash: true,
display_in_booking_form: true,
order_priority: 1,
...product,
});
test.describe("Superuser department custom-only pricing", () => {
test("toggles no-fallback pricing and shows 999999 for missing department prices", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
let customPricingOnly = false;
let receivedToggleBody: Record<string, unknown> | null = null;
const products = [
buildProduct({ id: 10, name: "Fallback Wash", price: 12345 }),
buildProduct({ id: 11, name: "Explicit Wash", price: 98765, order_priority: 2 }),
];
await page.setViewportSize({ width: 1280, height: 720 });
await seedAuthenticatedState(page, "superuser-department-pricing-token");
await mockApi(page, {
authenticated: true,
permissions: [
"superuser",
"user",
"list_products",
"list_departments",
"edit_department",
"superuser_fetch_department",
"superuser_fetch_department_prices",
"superuser_set_department_prices",
],
sessionData: {
group_id: 1,
},
});
await page.route("**/api/products**", async (route) => {
await route.fulfill(json({ data: products }));
});
await page.route("**/api/superuser/department/prices**", async (route) => {
await route.fulfill(
json({
data: [{ id: 100, department_id: 42, product_id: 11, price: 2222 }],
})
);
});
await page.route(/\/api\/superuser\/department(?:\?.*)?$/i, async (route) => {
await route.fulfill(
json({
data: {
id: 42,
name: "Custom Pricing Department",
description: "E2E department",
custom_pricing_only: customPricingOnly,
created_at: now,
updated_at: now,
},
})
);
});
await page.route("**/api/departments", async (route) => {
const request = route.request();
if (request.method() !== "PUT") {
await route.fallback();
return;
}
receivedToggleBody = request.postDataJSON() as Record<string, unknown>;
customPricingOnly = Boolean(receivedToggleBody.custom_pricing_only);
await route.fulfill(json({ data: { message: "Department updated successfully" } }));
});
await page.goto("/superuser/departments/42/pricing", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("department-custom-pricing-settings")).toBeVisible();
await expect(page.getByTestId("department-price-cell-10")).toHaveText(/-/);
await expect(page.getByTestId("department-price-cell-11")).toHaveText(/2222/);
await page.locator('label[for="department-custom-pricing-only"]').click();
await expect(page.getByTestId("department-custom-pricing-only-toggle")).toBeChecked();
await expect(page.getByTestId("department-price-cell-10")).toHaveText(/999999/);
await expect(page.getByTestId("department-price-cell-11")).toHaveText(/2222/);
expect(receivedToggleBody).toMatchObject({
id: "42",
custom_pricing_only: true,
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { endOfLocalDate, formatLocalDateOnly, parseLocalDateOnly, startOfLocalDate } from "@/services/dateOnly.js";
describe("date-only helpers", () => {
it("formats Date objects from their local calendar date", () => {
expect(formatLocalDateOnly(new Date(2026, 2, 31, 0, 0, 0, 0))).toBe("2026-03-31");
});
it("parses date input values as local calendar dates", () => {
const parsed = parseLocalDateOnly("2026-03-31");
expect(parsed.getFullYear()).toBe(2026);
expect(parsed.getMonth()).toBe(2);
expect(parsed.getDate()).toBe(31);
expect(formatLocalDateOnly(parsed)).toBe("2026-03-31");
});
it("keeps date-only prefixes from API timestamps without timezone reinterpretation", () => {
expect(formatLocalDateOnly("2026-03-31T23:30:00.000Z")).toBe("2026-03-31");
});
it("rejects impossible date input values", () => {
expect(Number.isNaN(parseLocalDateOnly("2026-02-31").getTime())).toBe(true);
});
it("builds local day boundaries from the selected date", () => {
expect(startOfLocalDate("2026-03-31").getHours()).toBe(0);
expect(startOfLocalDate("2026-03-31").getMinutes()).toBe(0);
expect(endOfLocalDate("2026-03-31").getHours()).toBe(23);
expect(endOfLocalDate("2026-03-31").getMinutes()).toBe(59);
expect(formatLocalDateOnly(endOfLocalDate("2026-03-31"))).toBe("2026-03-31");
});
});
+26
View File
@@ -122,6 +122,32 @@ describe("DatePeriodSelector mobile layout", () => {
expect(formatLocalDate(onSelectionChange.mock.calls[0][0])).toBe("2026-03-01");
expect(formatLocalDate(onSelectionChange.mock.calls[0][1])).toBe("2026-03-05");
});
it("emits the exact clicked date for start and end inputs", async () => {
sharedState.width.value = 1024;
const onSelectionChange = vi.fn();
const wrapper = mount(DatePeriodSelector, {
props: {
selection: {
startDate: new Date(2026, 2, 1, 0, 0, 0, 0),
endDate: new Date(2026, 2, 3, 23, 59, 59, 999),
},
onSelectionChange,
visibility: {
showUpdateButton: false,
},
},
});
await wrapper.get("[data-testid='date-period-start']").setValue("2026-03-31");
await wrapper.get("[data-testid='date-period-end']").setValue("2026-04-02");
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
expect(formatLocalDate(emittedSelection.startDate)).toBe("2026-03-31");
expect(formatLocalDate(emittedSelection.endDate)).toBe("2026-04-02");
expect(formatLocalDate(onSelectionChange.mock.calls.at(-1)[0])).toBe("2026-03-31");
expect(formatLocalDate(onSelectionChange.mock.calls.at(-1)[1])).toBe("2026-04-02");
});
});
describe("DatePeriodSelector month warning", () => {
@@ -2,6 +2,7 @@
import { defineComponent, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { formatLocalDateOnly } from "@/services/dateOnly.js";
const selectDateMock = vi.hoisted(() => vi.fn());
const sharedDateState = vi.hoisted(() => ({
@@ -39,10 +40,10 @@ const DatePeriodSelectorStub = defineComponent({
emits: ["update:selection"],
template: `
<div>
<button class="trigger-on-selection-change" @click="onSelectionChange(new Date('2026-03-24T00:00:00.000Z'), new Date('2026-03-29T00:00:00.000Z'))">
<button class="trigger-on-selection-change" @click="onSelectionChange(new Date(2026, 2, 24), new Date(2026, 2, 29))">
onSelectionChange
</button>
<button class="trigger-update-selection" @click="$emit('update:selection', { startDate: new Date('2026-04-01T00:00:00.000Z'), endDate: new Date('2026-04-30T00:00:00.000Z') })">
<button class="trigger-update-selection" @click="$emit('update:selection', { startDate: new Date(2026, 3, 1), endDate: new Date(2026, 3, 30) })">
update:selection
</button>
</div>
@@ -105,7 +106,7 @@ describe("DepartmentDashboardOverviewNavigation", () => {
});
const selector = wrapper.findComponent(DatePeriodSelectorStub);
expect(selector.props("selection").startDate.toISOString().split("T")[0]).toBe("2026-05-05");
expect(selector.props("selection").endDate.toISOString().split("T")[0]).toBe("2026-05-11");
expect(formatLocalDateOnly(selector.props("selection").startDate)).toBe("2026-05-05");
expect(formatLocalDateOnly(selector.props("selection").endDate)).toBe("2026-05-11");
});
});
@@ -755,6 +755,42 @@ describe("Invoicing period queue-driven refresh", () => {
expect(selfWashCalls()).toHaveLength(1);
});
it("loads Selvvask selector counts with the full selected-period limit", async () => {
requestMock.mockImplementation((url) => {
if (url === "/superuser/invoicing/period") {
return Promise.resolve(emptyPeriodResponse());
}
return Promise.resolve(
selfWashResponse(
[
{ id: 8101, linked_order_id: 7001 },
{
id: 8102,
linked_order_id: null,
automation: {
status: "accepted",
action: "attach_order",
},
},
{ id: 8103, linked_order_id: null },
],
3
)
);
});
const wrapper = mountRight();
await flushAll();
expect(wrapper.get("[data-testid='invoicing-period-view-selector-self_wash']").text()).toContain("Selvvask (2/3)");
expect(selfWashCalls()[0][2]).toMatchObject({
limit: 10000,
filters: "StartTime-date_from:2026-04-01,StartTime-date_to:2026-04-30",
order: "StartTime:desc",
});
});
it("ignores stale period responses that resolve after a newer search request", async () => {
const deferred = [];
requestMock.mockImplementation((url) => {
+1
View File
@@ -579,6 +579,7 @@ describe("MyWashStart", () => {
afterEach(() => {
consoleWarnSpy?.mockRestore();
vi.clearAllTimers();
vi.useRealTimers();
localStorage.clear();
});
@@ -0,0 +1,110 @@
// @vitest-environment jsdom
import { defineComponent } from "vue";
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
vi.mock("vue-i18n", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useI18n: () => ({
t: (value) => value,
}),
};
});
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
global: {
language: {
date_from: "From",
date_to: "To",
},
},
},
},
}));
import { formatLocalDateOnly } from "@/services/dateOnly.js";
import PaginationDisplayTemplateDate from "@/components/displays/pagination/templates/PaginationDisplayTemplateDate.vue";
import PaginationDisplayTemplateDates from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
import { dateFunctions } from "@/components/displays/pagination/PaginationDisplayDates.vue";
const PaginationDisplayItemColumnStub = defineComponent({
name: "PaginationDisplayItemColumn",
props: {
label: {
type: String,
required: true,
},
},
template: "<div><slot name='control' /></div>",
});
describe("pagination date selection", () => {
it("renders Date model values as the same local date", () => {
const wrapper = mount(PaginationDisplayTemplateDate, {
props: {
label: "Date",
modelValue: new Date(2026, 2, 31, 0, 0, 0, 0),
},
global: {
stubs: {
PaginationDisplayItemColumn: PaginationDisplayItemColumnStub,
},
},
});
expect(wrapper.get("input[type='date']").element.value).toBe("2026-03-31");
});
it("emits the clicked input date as the same local date", async () => {
const wrapper = mount(PaginationDisplayTemplateDate, {
props: {
label: "Date",
modelValue: new Date(2026, 2, 1, 0, 0, 0, 0),
},
global: {
stubs: {
PaginationDisplayItemColumn: PaginationDisplayItemColumnStub,
},
},
});
await wrapper.get("input[type='date']").setValue("2026-03-31");
const emittedDate = wrapper.emitted("update:date")?.at(-1)?.[0];
expect(formatLocalDateOnly(emittedDate)).toBe("2026-03-31");
});
it("uses the real first day of the month for month presets", () => {
const firstDay = dateFunctions.datePresetFunctions.month.firstDayOfMonth(new Date(2026, 4, 15, 12, 0, 0, 0));
expect(formatLocalDateOnly(firstDay)).toBe("2026-05-01");
expect(firstDay.getHours()).toBe(0);
expect(firstDay.getMinutes()).toBe(0);
});
it("emits both start and end updates from the paired date selector", async () => {
const wrapper = mount(PaginationDisplayTemplateDates, {
props: {
startDate: new Date(2026, 2, 1, 0, 0, 0, 0),
endDate: new Date(2026, 2, 31, 0, 0, 0, 0),
},
global: {
stubs: {
PaginationDisplayItemColumn: PaginationDisplayItemColumnStub,
},
},
});
const inputs = wrapper.findAll("input[type='date']");
await inputs[0].setValue("2026-04-01");
await inputs[1].setValue("2026-04-30");
expect(formatLocalDateOnly(wrapper.emitted("update:startDate")?.at(-1)?.[0])).toBe("2026-04-01");
expect(formatLocalDateOnly(wrapper.emitted("update:endDate")?.at(-1)?.[0])).toBe("2026-04-30");
});
});
@@ -40,6 +40,16 @@ describe("Playwright full E2E workflow grouping", () => {
expect(source).toContain("scripts/ci/runner-diagnostics.sh");
});
it("keeps PR E2E runner pressure bounded and diagnosable", () => {
const source = workflowSource();
expect(source).toMatch(/e2e-pr:[\s\S]*?max-parallel: 4/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_WORKERS: 1/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_VIDEO_MODE: on-first-retry/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_WORKERS="\$PLAYWRIGHT_WORKERS"/u);
expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_VIDEO_MODE="\$PLAYWRIGHT_VIDEO_MODE"/u);
});
it("allows CI to reduce Playwright video artifact pressure", () => {
const source = readFileSync(join(root, "playwright.config.ts"), "utf8");
+23 -2
View File
@@ -15,6 +15,21 @@ describe("Playwright PR mapping", () => {
);
});
it("maps superuser department pricing changes to custom-only pricing coverage", () => {
expect(specsFor("src/views/dashboards/superUserDashboard/department/DepartmentPricing.vue")).toContain(
"tests/e2e/superuser-department-pricing-custom-only.spec.ts"
);
expect(
specsFor("src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue")
).toContain("tests/e2e/superuser-department-pricing-custom-only.spec.ts");
expect(specsFor("src/components/session/token/SessionUser/Objects/Departments.vue")).toContain(
"tests/e2e/superuser-department-pricing-custom-only.spec.ts"
);
expect(specsFor("src/components/session/token/SessionUser/Objects/Departments.vue")).not.toContain(
"tests/e2e/userAuth.spec.ts"
);
});
it("maps department notification table changes to the admin notification E2E coverage", () => {
expect(
specsFor("src/components/displays/department/notifications/departmentNotificationsPhoneTable.vue")
@@ -33,9 +48,15 @@ describe("Playwright PR mapping", () => {
);
});
it("keeps full-slice ownership metadata out of broad PR smoke fallback", () => {
expect(triggersFallback("scripts/run-playwright-pr.mjs")).toBe(true);
it("keeps PR runner and full-slice metadata edits out of broad PR smoke fallback", () => {
expect(triggersFallback("scripts/run-playwright-pr.mjs")).toBe(false);
expect(triggersFallback("scripts/run-playwright-ci-parallel.mjs")).toBe(true);
expect(triggersFallback("scripts/run-playwright-full-slice.mjs")).toBe(false);
});
it("maps limited backoffice changes to the limited backoffice E2E coverage", () => {
expect(specsFor("src/views/backoffice/LimitedBackofficeEmployees.vue")).toContain(
"tests/e2e/limited-backoffice.spec.ts"
);
});
});
+1
View File
@@ -95,6 +95,7 @@ afterEach(async () => {
await Promise.resolve();
await Promise.resolve();
vi.clearAllTimers();
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllGlobals();
+17 -1
View File
@@ -100,6 +100,10 @@ const xlvaskUsagePaginationSource = readFileSync(
join(root, "src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue"),
"utf8"
);
const xlvaskUsagePeriodConstantsSource = readFileSync(
join(root, "src/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js"),
"utf8"
);
const xlvaskUsageOrdersTableSource = readFileSync(
join(root, "src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue"),
"utf8"
@@ -367,6 +371,8 @@ describe("Invoice orders pagination contract", () => {
expect(invoiceOrdersPaginationSource).toContain(
"const onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {"
);
expect(invoiceOrdersPaginationSource).toContain("formatLocalDateOnly(newSelectionStartDate)");
expect(invoiceOrdersPaginationSource).toContain("formatLocalDateOnly(newSelectionToDate)");
expect(invoiceOrdersPaginationSource).toContain("date_from.value = formattedStartDate;");
expect(invoiceOrdersPaginationSource).toContain("date_to.value = formattedEndDate;");
expect(invoiceOrdersPaginationSource).toContain('setFilter("created_at-date_from", formattedStartDate, true);');
@@ -477,7 +483,7 @@ describe("Periode tab contract", () => {
expect(periodViewSelfWashSource).toContain(':load-all-at-once="true"');
expect(xlvaskUsagePaginationSource).toContain("inheritPeriodFilters");
expect(xlvaskUsagePaginationSource).toContain("loadAllAtOnce");
expect(xlvaskUsagePaginationSource).toContain("const SELFWASH_PERIOD_ALL_LIMIT = 10000");
expect(xlvaskUsagePeriodConstantsSource).toContain("export const SELFWASH_PERIOD_ALL_LIMIT = 10000");
expect(xlvaskUsagePaginationSource).toContain("setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false)");
expect(xlvaskUsagePaginationSource).toContain(
"const shouldShowLocalFilters = computed(() => !props.inheritPeriodFilters)"
@@ -494,6 +500,7 @@ describe("Periode tab contract", () => {
expect(periodRightSource).toContain("getCachedSelfWashCounts");
expect(periodRightSource).toContain("setCachedSelfWashCounts");
expect(periodRightSource).toContain("'/modules/xlvask/services/usage/orders'");
expect(periodRightSource).toContain("limit: SELFWASH_PERIOD_ALL_LIMIT");
expect(periodRightSource).toContain("StartTime-date_from:${dates.computed.formattedStartDate.value}");
expect(periodRightSource).toContain("StartTime-date_to:${dates.computed.formattedEndDate.value}");
expect(periodRightSource).toContain("if (type.name === 'self_wash')");
@@ -502,6 +509,15 @@ describe("Periode tab contract", () => {
expect(periodRightSource).not.toContain("showProgress: false");
});
it("keeps Selvvask usage pagination scoped to the period view", () => {
expect(xlvaskUsagePaginationSource).toContain("usePaginatedList");
expect(xlvaskUsagePaginationSource).toContain("provide(PaginatedListKey, paginatedList)");
expect(xlvaskUsageOrdersTableSource).toContain("usePaginatedListInstance");
expect(xlvaskUsageOrdersTableSource).toContain(
"const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();"
);
});
it("keeps Selvvask usage entries wrapped inside the period content area", () => {
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-primary");
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-status");