Compare commits

..
77 changed files with 639 additions and 1682 deletions
+4
View File
@@ -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
@@ -223,6 +225,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" \
+15 -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,
},
@@ -92,6 +96,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,7 +5,6 @@ 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,
@@ -116,11 +115,23 @@ const availableYears = computed(() => (
));
const formatDateInputValue = (date: Date) => {
return formatLocalDateOnly(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}`;
};
const parseDateInputValue = (value: string) => {
return parseLocalDateOnly(value);
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return new Date(value);
}
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
};
const isSameDateInputValue = (left: Date, right: Date) => (
@@ -30,7 +30,6 @@ 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 {
@@ -378,8 +377,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:${
todayLocalDateOnly()
},created_at-date_to:${todayLocalDateOnly()}`,
new Date().toISOString().split("T")[0]
},created_at-date_to:${new Date().toISOString().split("T")[0]}`,
limit: 5,
});
@@ -22,7 +22,7 @@ import PosDepartmentStep2MobileVehicleSelection
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import PosDepartmentStepMobile2FloatingCart
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
import { isAddonRestricted, canBuyAdditionalServices, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue";
import { isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
const props = defineProps({
label: {
@@ -46,7 +46,6 @@ const props = defineProps({
},
});
const canSelectAdditionalItems = computed(() => canBuyAdditionalServices());
const checked = ref(props.defaultChecked);
// Function to generate a summary from the last order
function generateSummary(order: PosOrder): string {
@@ -73,12 +72,6 @@ const displaySubtitle = computed(() => {
});
// Emit event on toggle
function onToggle(isOpen: boolean) {
if (isOpen && !canSelectAdditionalItems.value) {
checked.value = false;
pos.views.additionalItemSelection.value = false;
return;
}
checked.value = isOpen;
// Open the additional item selection view if toggled open
if (isOpen) {
@@ -125,45 +118,17 @@ const getAvailableAdditionalItems = () => {
}));
}
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
const isAdditionalItemRestricted = (product: PosProduct) => {
if (!canSelectAdditionalItems.value) {
return true;
}
if (isProductRestricted(product)) {
return true;
}
return isAddonRestricted(convertProductToAddon(product));
}
watch(canSelectAdditionalItems, (canSelect) => {
if (!canSelect) {
checked.value = false;
pos.views.additionalItemSelection.value = false;
}
});
// Computed property to filter out restricted additional items based on customer attributes
const filteredAdditionalItems = computed(() => {
// If additional services are restricted, return empty array
if (!canSelectAdditionalItems.value) {
if (!canBuyAdditionalServices()) {
return [];
}
// Filter out individually restricted addons
return availableAdditionalItems.value.filter((addon: Addon) => {
if (isAddonRestricted(addon)) {
return false;
}
if (addon.product && isProductRestricted(addon.product)) {
return false;
}
return true;
});
return availableAdditionalItems.value.filter((addon: Addon) => !isAddonRestricted(addon));
});
const onClickAddOtherProduct = () => {
if (!canSelectAdditionalItems.value) {
pos.views.additionalItemSelection.value = false;
return;
}
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
}
@@ -194,9 +159,7 @@ watch(() => pos.transactionItems.additionalItems.value, (newVal) => {
}, { deep: true });
const onClickAddProduct = async (product: PosProduct) => {
if (isAdditionalItemRestricted(product)) {
return;
}
// If the product requires note, open note input.
pos.transactionItems.addAdditionalItem(product);
// If the view is fullscreen, close it after adding
//if (pos.views.additionalItemSelection.value) {
@@ -208,7 +171,7 @@ const onClickAddProduct = async (product: PosProduct) => {
<template>
<div data-testid="pos-mobile-additional-items">
<!-- Minimal view, when not set as fullscreen view -->
<WhiteBoxCard :toggleable="canSelectAdditionalItems && pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
<WhiteBoxCard :toggleable="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
@toggle="onToggle"
:forceState="(pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0) ? true : (pos.views.additionalItemSelection.value)"
@@ -238,7 +201,7 @@ const onClickAddProduct = async (product: PosProduct) => {
</div>
</template>
<!-- Footer -->
<template #footer v-if="canSelectAdditionalItems">
<template #footer>
<!-- Select other product button -->
<a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct">
<span class="icon">
@@ -249,7 +212,7 @@ const onClickAddProduct = async (product: PosProduct) => {
</template>
</WhiteBoxCard>
<!-- Fullscreen view, when selecting other products -->
<template v-else-if="canSelectAdditionalItems">
<template v-else>
<div data-testid="pos-mobile-additional-items-selection">
<!-- Categories of products -->
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
@@ -12,7 +12,6 @@ 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 */
@@ -127,7 +126,7 @@ const liveTransactions = ref(null);
const isLoading = ref(true);
const syncListTransactionHistory = async () => {
let dateToday = todayLocalDateOnly(); // Get today's date in YYYY-MM-DD format
let dateToday = new Date().toISOString().split("T")[0]; // 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
@@ -1,7 +1,6 @@
<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
@@ -28,7 +27,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(), 1, 0, 0, 0, 0); // Set to the first day of the month at 00:00:00
return new Date(date.getFullYear(), date.getMonth(), 2, 0, 0, 1, 0); // Set to the first day of the month at 00:00:01
},
// Get the last day of the month based on the provided date last day at 23:59:59
lastDayOfMonth: (date: Date): Date => {
@@ -117,7 +116,7 @@ export const datePresets = <datePreset[]>[
*/
const convertToISO = (date: Date): string => {
return formatLocalDateOnly(date); // Convert to YYYY-MM-DD format
return date.toISOString().split('T')[0]; // Convert to YYYY-MM-DD format
};
export const dateFunctions = {
@@ -151,4 +150,4 @@ export default defineComponent({
},
});
</script>
<template></template>
<template></template>
@@ -50,7 +50,6 @@ 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 { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
@@ -64,7 +63,7 @@ const parseInitialDate = (value) => {
return new Date();
}
const parsed = parseLocalDateOnly(value);
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
};
@@ -130,7 +129,7 @@ const dateFrom = ref(parseInitialDate(props.initialDateFrom));
const dateTo = ref(parseInitialDate(props.initialDateTo));
const parsedDate = (date) => {
return parseLocalDateOnly(date);
return new Date(date);
};
const reloadScheduled = ref(false);
@@ -149,14 +148,14 @@ const actions = {
from: {
select: (date) => {
dateFrom.value = parsedDate(date);
setFilter("StartTime-date_from", formatLocalDateOnly(date), false);
setFilter("StartTime-date_from", date.toISOString().split("T")[0], false);
scheduleReload();
}
},
to: {
select: (date) => {
dateTo.value = parsedDate(date);
setFilter("StartTime-date_to", formatLocalDateOnly(date), false);
setFilter("StartTime-date_to", date.toISOString().split("T")[0], 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 = formatLocalDateOnly(newSelectionStartDate);
const formattedEndDate = formatLocalDateOnly(newSelectionToDate);
const formattedStartDate = newSelectionStartDate.toISOString().split("T")[0];
const formattedEndDate = newSelectionToDate.toISOString().split("T")[0];
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: parseLocalDateOnly(date_from || todayLocalDateOnly()), endDate: parseLocalDateOnly(date_to || todayLocalDateOnly()) }"/>
v-bind:selection="{ startDate: date_from ? new Date(date_from) : new Date(now()), endDate: date_to ? new Date(date_to) : new Date(now()) }"/>
</div>
</template>
<template #default>
@@ -28,7 +28,6 @@ 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();
@@ -53,7 +52,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", todayLocalDateOnly(), false);
setFilter("date", new Date().toISOString().split("T")[0], false);
setOrder("created_at", "desc");
}
// If the route starts with /superuser, set the endpoint to /bookings
@@ -82,7 +81,7 @@ loadList();
const showingToday = ref(true);
watch(() => getFilter("date"), (value) => {
showingToday.value = value === todayLocalDateOnly();
showingToday.value = value === new Date().toISOString().split("T")[0];
});
// If the screen is mobile, set the is small variable to true
@@ -152,7 +151,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 ? todayLocalDateOnly() : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
<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 }" />
<label for="today"></label>
</div>
</div>
@@ -189,4 +188,4 @@ const showNewOrderBookingsPortal = () => {
<style scoped>
</style>
</style>
@@ -8,7 +8,6 @@ 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();
/**
@@ -53,9 +52,11 @@ 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", startOfLocalDate(val).toISOString(), false);
setFilter("datetime-date_to", endOfLocalDate(val).toISOString(), false);
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
}
if (autoLoadList) {
loadList();
@@ -94,7 +95,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: todayLocalDateOnly() } }, false);
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
}
if (setFilterKey) {
setFilter(key, value, false);
@@ -169,7 +170,7 @@ onMounted(() => {
<div class="select">
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
<option value="*">{{ t("common.all") }}</option>
<option :value="todayLocalDateOnly()">{{ t("common.yes") }}</option>
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
</select>
</div>
</div>
@@ -192,7 +193,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange({
target: { value: event.target.checked ? todayLocalDateOnly() : '*' },
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
});
}
"
@@ -225,7 +226,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange(
{ target: { value: event.target.checked ? todayLocalDateOnly() : '*' } },
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
false
);
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
@@ -52,10 +52,6 @@ loadList();
:columnLabels="{ type: SessionUser.objects.vehicles.columns.type.label }"
/>
</div>
<div v-if="$slots.actions" class="vehicles-pagination__add-action">
<label class="label is-small">&nbsp;</label>
<slot name="actions" />
</div>
<!-- Create a new vehicle, if the route is /user -->
<div class="vehicles-pagination__add-action"
v-if="router.currentRoute.value.path.startsWith('/user')">
@@ -1,7 +1,6 @@
<script setup lang="ts">
import PaginationDisplayItemColumn from "@/components/displays/pagination/PaginationDisplayItemColumn.vue";
import { computed } from "vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
import { ref, computed } from "vue";
const props = defineProps({
label: {
type: String,
@@ -20,7 +19,7 @@ defineExpose({
});
const formattedDate = computed(() => {
return formatLocalDateOnly(date.value);
return date.value.toISOString().split('T')[0];
});
// This component is used to display a date input in a pagination display item column.
@@ -37,7 +36,8 @@ const formattedDate = computed(() => {
class="input"
:value="formattedDate"
@input="(e) => {
const newDate = parseLocalDateOnly(e.target.value);
const newDate = new Date(e.target.value);
console.log('Selected date:', newDate);
emit('update:date', newDate);
}"
/>
@@ -47,4 +47,4 @@ const formattedDate = computed(() => {
<style scoped>
</style>
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
@@ -9,7 +10,7 @@ import PaginationDisplayTemplateButton
from "@/components/displays/pagination/templates/PaginationDisplayTemplateButton.vue";
import { datePresets , dateFunctions} from '@/components/displays/pagination/PaginationDisplayDates.vue';
defineProps({
const props = defineProps({
startDate: {
type: Date,
required: true,
@@ -45,7 +46,6 @@ 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,7 +13,6 @@ 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}`;
@@ -238,8 +237,7 @@ 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 = todayLocalDateOnly();
const previousDate = yesterdayLocalDateOnly();
const currentDate = new Date().toISOString().split("T")[0];
/**
* Sort the bookings by date
@@ -364,7 +362,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 === previousDate ? $t('tables.bookings.yesterday') : object.date }}
{{ 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 }}
<!-- Number of bookings on the date -->
({{ $t('tables.bookings.bookings_count', { count: objects.filter((booking) => booking.date === object.date).length }) }})
</span>
@@ -1,241 +0,0 @@
<script setup>
import { computed, nextTick, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import CustomerSearchSelect from "@/components/search/economic/CustomerSearchSelect.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const emit = defineEmits(["close", "created"]);
const { t } = useI18n();
const selectedCustomer = ref(null);
const registrationNumber = ref("");
const vehicleType = ref("");
const washSubscription = ref(false);
const reference = ref("");
const vehicleTypeOptions = ref([]);
const isLoadingVehicleTypes = ref(false);
const isSubmitting = ref(false);
const errorMessage = ref("");
const vehicleTypeError = ref("");
const getCustomerNumber = (customer) => {
const parsedValue = Number.parseInt(
String(customer?.customerNumber ?? customer?.customer_number ?? customer?.customer_id ?? customer?.id ?? ""),
10
);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const selectedCustomerNumber = computed(() => getCustomerNumber(selectedCustomer.value));
const normalizedVehicleType = computed(() => {
if (vehicleType.value === "") {
return null;
}
const parsedValue = Number.parseInt(String(vehicleType.value), 10);
return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : null;
});
const normalizedRegistrationNumber = computed(() => {
return registrationNumber.value.trim().toUpperCase();
});
const canSubmit = computed(() => {
return (
!isSubmitting.value &&
!isLoadingVehicleTypes.value &&
selectedCustomerNumber.value !== null &&
normalizedRegistrationNumber.value.length > 0 &&
normalizedVehicleType.value !== null
);
});
const parseErrorMessage = (error) => {
return SessionUser.functions.parseErrorMessage(error) || t("vehicles.add_modal.error");
};
const loadVehicleTypes = async () => {
isLoadingVehicleTypes.value = true;
vehicleTypeError.value = "";
try {
vehicleTypeOptions.value = await SessionUser.objects.vehicles.columns.type.options();
} catch (error) {
vehicleTypeError.value = parseErrorMessage(error) || t("vehicles.add_modal.type_load_error");
} finally {
isLoadingVehicleTypes.value = false;
}
};
const closeModal = () => {
if (!isSubmitting.value) {
emit("close");
}
};
const submitVehicle = async () => {
if (!canSubmit.value) {
errorMessage.value = t("vehicles.add_modal.validation_error");
return;
}
isSubmitting.value = true;
errorMessage.value = "";
try {
const response = await SessionUser.objects.vehicles.add(
normalizedVehicleType.value,
normalizedRegistrationNumber.value,
washSubscription.value,
selectedCustomerNumber.value,
reference.value.trim() || null
);
emit("created", response);
} catch (error) {
errorMessage.value = parseErrorMessage(error);
} finally {
isSubmitting.value = false;
}
};
onMounted(async () => {
await loadVehicleTypes();
await nextTick();
document.getElementById("superuser-add-vehicle-customer-search")?.focus();
});
</script>
<template>
<div class="modal is-active" data-testid="superuser-add-vehicle-modal">
<div class="modal-background" @click="closeModal"></div>
<div class="modal-card superuser-add-vehicle-modal">
<header class="modal-card-head">
<p class="modal-card-title">{{ t("vehicles.add_modal.title") }}</p>
<button
class="delete"
type="button"
:aria-label="t('common.close')"
data-testid="superuser-add-vehicle-close"
@click="closeModal"
></button>
</header>
<section class="modal-card-body">
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="superuser-add-vehicle-error">
{{ errorMessage }}
</div>
<CustomerSearchSelect
v-model="selectedCustomer"
input-id="superuser-add-vehicle-customer-search"
test-id-prefix="superuser-add-vehicle-customer"
:disabled="isSubmitting"
:placeholder="t('vehicles.add_modal.customer_placeholder')"
/>
<div class="field">
<label class="label" for="superuser-add-vehicle-registration">{{ t("vehicles.form.license_plate") }}</label>
<div class="control">
<input
id="superuser-add-vehicle-registration"
v-model="registrationNumber"
class="input"
type="text"
autocomplete="off"
:placeholder="t('vehicles.add_modal.registration_placeholder')"
:disabled="isSubmitting"
data-testid="superuser-add-vehicle-registration"
/>
</div>
</div>
<div class="field">
<label class="label" for="superuser-add-vehicle-type">{{ t("vehicles.form.type") }}</label>
<div class="control" :class="{ 'is-loading': isLoadingVehicleTypes }">
<div class="select is-fullwidth">
<select
id="superuser-add-vehicle-type"
v-model="vehicleType"
:disabled="isSubmitting || isLoadingVehicleTypes || vehicleTypeOptions.length === 0"
data-testid="superuser-add-vehicle-type"
>
<option disabled value="">{{ t("vehicles.add_modal.type_placeholder") }}</option>
<option v-for="option in vehicleTypeOptions" :key="option.id" :value="option.id">
{{ option.name }}
</option>
</select>
</div>
</div>
<p v-if="vehicleTypeError" class="help is-danger" data-testid="superuser-add-vehicle-type-error">
{{ vehicleTypeError }}
</p>
</div>
<div class="field">
<label class="checkbox">
<input
v-model="washSubscription"
type="checkbox"
:disabled="isSubmitting"
data-testid="superuser-add-vehicle-wash-subscription"
/>
{{ t("objects.vehicles.columns.wash_subscription") }}
</label>
</div>
<div class="field">
<label class="label" for="superuser-add-vehicle-reference">{{ t("common.reference") }}</label>
<div class="control">
<input
id="superuser-add-vehicle-reference"
v-model="reference"
class="input"
type="text"
autocomplete="off"
:placeholder="t('vehicles.add_modal.reference_placeholder')"
:disabled="isSubmitting"
data-testid="superuser-add-vehicle-reference"
/>
</div>
</div>
</section>
<footer class="modal-card-foot is-justify-content-flex-end">
<button
class="button"
type="button"
:disabled="isSubmitting"
data-testid="superuser-add-vehicle-cancel"
@click="closeModal"
>
{{ t("common.cancel") }}
</button>
<button
class="button is-link"
type="button"
:class="{ 'is-loading': isSubmitting }"
:disabled="!canSubmit"
data-testid="superuser-add-vehicle-submit"
@click="submitVehicle"
>
{{ t("vehicles.add_modal.submit") }}
</button>
</footer>
</div>
</div>
</template>
<style scoped>
.superuser-add-vehicle-modal {
max-width: min(44rem, calc(100vw - 2rem));
width: 100%;
}
.modal-card-body {
overflow: visible;
}
</style>
@@ -19,17 +19,17 @@ const SENSITIVE_QUERY_KEYS = /authorization|password|passwd|secret|token|api[_-]
const FALLBACK_LABELS = {
"error_report.button": "Report error",
"error_report.title": "Report error",
"error_report.subtitle": "Send recent error details to support. A screenshot is attached when available.",
"error_report.subtitle": "Send the current screen and recent error details to support.",
"error_report.before_error": "What were you doing before the error occurred?",
"error_report.expected": "What did you expect would happen?",
"error_report.actual": "What actually happened?",
"error_report.before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"error_report.expected_placeholder": "Describe the result you expected to see.",
"error_report.actual_placeholder": "Describe what you saw instead, including any error text.",
"error_report.consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
"error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"error_report.submit": "Submit report",
"error_report.submitted": "Error report submitted.",
"error_report.capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
"error_report.capture_failed": "The screen capture failed. Please try again.",
"error_report.submit_failed": "The error report could not be submitted.",
"error_report.required": "All fields and data collection acceptance are required.",
};
@@ -277,18 +277,15 @@ const submit = async () => {
isSubmitting.value = true;
try {
let screenshot = null;
let screenshot = "";
try {
screenshot = await captureScreenshot();
} catch {
captureError.value = tr("error_report.capture_failed");
return;
}
const context = buildContext();
context.screenshot_attachment = {
status: screenshot ? "stored" : "capture_failed",
attached: Boolean(screenshot),
};
await submitErrorReport({
before_error: form.before_error.trim(),
expected: form.expected.trim(),
@@ -343,7 +340,7 @@ const submit = async () => {
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
{{ tr("error_report.submitted") }}
</div>
<div v-if="captureError" class="notification is-warning is-light" data-testid="error-report-capture-warning">
<div v-if="captureError" class="notification is-danger is-light">
{{ captureError }}
</div>
<div v-if="submitError" class="notification is-danger is-light">
+3 -4
View File
@@ -4,7 +4,6 @@ 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;
@@ -74,9 +73,9 @@ const emphasis = ref({
"department:" +
department_id +
",status:pending,date-date_from:" +
todayLocalDateOnly() +
new Date().toISOString().split("T")[0] +
",date-date_to:" +
todayLocalDateOnly(),
new Date().toISOString().split("T")[0],
limit: 1, // Limit to 1 booking, we only need to know if there are any bookings or not
page: 1,
})
@@ -98,7 +97,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: todayLocalDateOnly(),
date: new Date().toISOString().split("T")[0],
id: department_id, // This refers to the department ID
})
.then((response) => {
@@ -1,288 +0,0 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { isSearching, searchCustomer, searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
const props = defineProps({
modelValue: {
type: Object,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
inputId: {
type: String,
default: "customer-search-select-input",
},
testIdPrefix: {
type: String,
default: "customer-search-select",
},
placeholder: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue", "selected", "cleared"]);
const { t } = useI18n();
const searchQuery = ref("");
const showResults = ref(false);
const selectedResultIndex = ref(-1);
const getCustomerNumber = (customer) => {
const parsedValue = Number.parseInt(
String(customer?.customerNumber ?? customer?.customer_number ?? customer?.customer_id ?? customer?.id ?? ""),
10
);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const getCustomerName = (customer) => {
return String(customer?.name ?? customer?.customer_name ?? customer?.customerName ?? "").trim();
};
const getCustomerCity = (customer) => {
return String(customer?.city ?? customer?.address_city ?? "").trim();
};
const formatCustomer = (customer) => {
if (!customer) {
return "";
}
const name = getCustomerName(customer);
const customerNumber = getCustomerNumber(customer);
return [name, customerNumber ? `#${customerNumber}` : null].filter(Boolean).join(" - ");
};
const selectedCustomerNumber = computed(() => getCustomerNumber(props.modelValue));
const selectedCustomerName = computed(() => getCustomerName(props.modelValue));
const selectedCustomerCity = computed(() => getCustomerCity(props.modelValue));
const hasResults = computed(() => searchCustomerResults.value.length > 0);
const placeholderText = computed(() => props.placeholder || t("vehicles.add_modal.customer_placeholder"));
const resetSearchResults = () => {
searchCustomer(null);
selectedResultIndex.value = -1;
};
const setSelectedCustomer = (customer) => {
emit("update:modelValue", customer);
emit("selected", customer);
searchQuery.value = formatCustomer(customer);
showResults.value = false;
selectedResultIndex.value = -1;
};
const clearSelectedCustomer = async () => {
emit("update:modelValue", null);
emit("cleared");
searchQuery.value = "";
showResults.value = false;
resetSearchResults();
await nextTick();
document.getElementById(props.inputId)?.focus();
};
const handleSearchInput = () => {
if (props.modelValue) {
emit("update:modelValue", null);
}
const query = searchQuery.value.trim();
selectedResultIndex.value = -1;
if (!query) {
showResults.value = false;
resetSearchResults();
return;
}
showResults.value = true;
searchCustomer(query);
};
const handleFocus = () => {
if (searchQuery.value.trim() && hasResults.value) {
showResults.value = true;
}
};
const handleBlur = () => {
window.setTimeout(() => {
showResults.value = false;
selectedResultIndex.value = -1;
}, 150);
};
const handleKeydown = (event) => {
if (!showResults.value || !hasResults.value) {
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
selectedResultIndex.value = Math.min(selectedResultIndex.value + 1, searchCustomerResults.value.length - 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
selectedResultIndex.value = Math.max(selectedResultIndex.value - 1, 0);
} else if (event.key === "Enter") {
event.preventDefault();
const selectedCustomer = searchCustomerResults.value[selectedResultIndex.value] || searchCustomerResults.value[0];
if (selectedCustomer) {
setSelectedCustomer(selectedCustomer);
}
} else if (event.key === "Escape") {
showResults.value = false;
selectedResultIndex.value = -1;
}
};
watch(
() => props.modelValue,
(customer) => {
if (customer) {
searchQuery.value = formatCustomer(customer);
} else if (!document.activeElement || document.activeElement.id !== props.inputId) {
searchQuery.value = "";
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
resetSearchResults();
});
</script>
<template>
<div class="customer-search-select">
<div class="field">
<label class="label" :for="inputId">{{ t("vehicles.add_modal.customer_label") }}</label>
<div class="control has-icons-left" :class="{ 'is-loading': isSearching }">
<input
:id="inputId"
v-model="searchQuery"
class="input"
type="text"
autocomplete="off"
:placeholder="placeholderText"
:disabled="disabled"
:aria-expanded="showResults && hasResults"
:data-testid="`${testIdPrefix}-input`"
@input="handleSearchInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
/>
<span class="icon is-left">
<i class="fas fa-search"></i>
</span>
</div>
</div>
<div
v-if="showResults && hasResults"
class="dropdown is-active customer-search-select__dropdown"
:data-testid="`${testIdPrefix}-results`"
>
<div class="dropdown-menu customer-search-select__menu" role="listbox">
<div class="dropdown-content">
<button
v-for="(customer, index) in searchCustomerResults"
:key="getCustomerNumber(customer) || index"
type="button"
class="dropdown-item customer-search-select__option"
:class="{ 'is-active': selectedResultIndex === index }"
:data-testid="`${testIdPrefix}-option-${index}`"
@mousedown.prevent="setSelectedCustomer(customer)"
>
<span class="customer-search-select__option-main">{{ getCustomerName(customer) }}</span>
<span class="customer-search-select__option-meta">
#{{ getCustomerNumber(customer) }}
<template v-if="getCustomerCity(customer)"> · {{ getCustomerCity(customer) }}</template>
</span>
</button>
</div>
</div>
</div>
<div v-if="modelValue" class="customer-search-select__selected" :data-testid="`${testIdPrefix}-selected`">
<div>
<p class="has-text-weight-semibold">{{ t("vehicles.add_modal.selected_customer") }}</p>
<p>{{ selectedCustomerName }}</p>
<p class="is-size-7 has-text-grey">
#{{ selectedCustomerNumber }}
<span v-if="selectedCustomerCity"> · {{ selectedCustomerCity }}</span>
</p>
</div>
<button
type="button"
class="button is-small is-light"
:disabled="disabled"
:data-testid="`${testIdPrefix}-clear`"
@click="clearSelectedCustomer"
>
{{ t("common.clear") }}
</button>
</div>
</div>
</template>
<style scoped>
.customer-search-select {
position: relative;
}
.customer-search-select__dropdown,
.customer-search-select__menu {
width: 100%;
}
.customer-search-select__dropdown {
left: 0;
position: absolute;
right: 0;
top: 4.75rem;
z-index: 40;
}
.customer-search-select__option {
align-items: flex-start;
background: transparent;
border: 0;
display: flex;
flex-direction: column;
text-align: left;
white-space: normal;
width: 100%;
}
.customer-search-select__option-main {
font-weight: 600;
}
.customer-search-select__option-meta {
color: #6b7280;
font-size: 0.8rem;
}
.customer-search-select__selected {
align-items: flex-start;
background: #f5f8fc;
border: 1px solid #d8e2ef;
border-radius: 6px;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
padding: 0.85rem 1rem;
}
</style>
@@ -5,7 +5,6 @@ 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);
@@ -242,7 +241,7 @@ export const Bookings = {
'/bookings',
'GET',
{
filters: `department:${department},status:pending,date-date_from:${todayLocalDateOnly()},date-date_to:${todayLocalDateOnly()}`,
filters: `department:${department},status:pending,date-date_from:${new Date().toISOString().split('T')[0]},date-date_to:${new Date().toISOString().split('T')[0]}`,
limit: 100,
page: 1
}
@@ -290,4 +289,4 @@ export const Bookings = {
);
}
};
</script>
</script>
@@ -5,7 +5,6 @@ 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);
@@ -81,7 +80,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 : todayLocalDateOnly()
date: date ? date : new Date().toISOString().split('T')[0]
}
);
},
@@ -1,7 +1,6 @@
<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
@@ -231,10 +230,10 @@ export const DepartmentTimeBookingsEntries = {
if (!dateFrom) {
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
dateFrom = formatLocalDateOnly(lastWeek); // Format as YYYY-MM-DD
dateFrom = lastWeek.toISOString().split('T')[0]; // Format as YYYY-MM-DD
}
if (!dateTo) {
dateTo = todayLocalDateOnly(); // Format as YYYY-MM-DD
dateTo = new Date().toISOString().split('T')[0]; // 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
@@ -280,4 +279,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));
},
+11 -17
View File
@@ -3455,6 +3455,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'}",
@@ -3478,17 +3485,17 @@
"error_report": {
"button": "Rapporter fejl",
"title": "Rapporter fejl",
"subtitle": "Send de seneste fejldetaljer til support. Et skærmbillede vedhæftes, når det er muligt.",
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
"before_error": "Hvad lavede du, før fejlen opstod?",
"expected": "Hvad forventede du, der ville ske?",
"actual": "Hvad skete der faktisk?",
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
"consent": "Jeg accepterer, at de seneste request-fejl, Vue-fejl, browseroplysninger, mine svar og et app-skærmbillede, når det er muligt, indsamles til fejlfinding.",
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
"submit": "Send rapport",
"submitted": "Fejlrapporten er sendt.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Rapporten sendes uden skærmbillede.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
"submit_failed": "Fejlrapporten kunne ikke sendes.",
"required": "Alle felter og accept af dataindsamling er påkrævet.",
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
@@ -4285,6 +4292,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'}",
@@ -5932,20 +5940,6 @@
},
"vehicles": {
"add": "@:{'words.generated.tilføj'} @:{'words.generated.køretøj'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Søg efter kundenavn eller kundenummer",
"error": "Køretøjet kunne ikke tilføjes.",
"no_customer_results": "Ingen kunder fundet",
"reference_placeholder": "Valgfri reference",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Valgt kunde",
"submit": "Tilføj køretøj",
"title": "Tilføj køretøj",
"type_load_error": "Køretøjstyper kunne ikke indlæses.",
"type_placeholder": "Vælg køretøjstype",
"validation_error": "Vælg kunde, registreringsnummer og køretøjstype."
},
"brand": "@:{'templates.generated.compat.admin.pos.make'}",
"color": "Farve",
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
+3 -3
View File
@@ -3589,17 +3589,17 @@
"error_report": {
"button": "Fehler melden",
"title": "Fehler melden",
"subtitle": "Sende die letzten Fehlerdetails an den Support. Ein Screenshot wird angehängt, wenn möglich.",
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
"expected": "Was hatten Sie erwartet?",
"actual": "Was ist tatsächlich passiert?",
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
"consent": "Ich akzeptiere, dass letzte Request-Fehler, Vue-Fehler, Browserdetails, meine Antworten und ein App-Screenshot, wenn möglich, zur Fehlersuche erfasst werden.",
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
"submit": "Bericht senden",
"submitted": "Fehlerbericht gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Der Bericht wird ohne Screenshot gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
+11 -17
View File
@@ -3287,6 +3287,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'}",
@@ -3310,17 +3317,17 @@
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send recent error details to support. A screenshot is attached when available.",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"expected_placeholder": "Describe the result you expected to see.",
"actual_placeholder": "Describe what you saw instead, including any error text.",
"consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
@@ -4117,6 +4124,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'}",
@@ -5764,20 +5772,6 @@
},
"vehicles": {
"add": "@.capitalize:{'words.generated.add'} @:{'words.generated.vehicle'}",
"add_modal": {
"customer_label": "Customer",
"customer_placeholder": "Search by customer name or number",
"error": "Unable to add vehicle.",
"no_customer_results": "No customers found",
"reference_placeholder": "Optional reference",
"registration_placeholder": "Registration number",
"selected_customer": "Selected customer",
"submit": "Add vehicle",
"title": "Add vehicle",
"type_load_error": "Unable to load vehicle types.",
"type_placeholder": "Select vehicle type",
"validation_error": "Select a customer, registration number, and vehicle type."
},
"brand": "Brand",
"color": "Color",
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
+8 -14
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'}",
@@ -5834,20 +5842,6 @@
"vehicles": {
"actions": "@:common.actions",
"add": "@:{'templates.generated.compat.vehicles.add'}",
"add_modal": {
"customer_label": "@:{'templates.generated.compat.vehicles.add_modal.customer_label'}",
"customer_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.customer_placeholder'}",
"error": "@:{'templates.generated.compat.vehicles.add_modal.error'}",
"no_customer_results": "@:{'templates.generated.compat.vehicles.add_modal.no_customer_results'}",
"reference_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.reference_placeholder'}",
"registration_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.registration_placeholder'}",
"selected_customer": "@:{'templates.generated.compat.vehicles.add_modal.selected_customer'}",
"submit": "@:{'templates.generated.compat.vehicles.add_modal.submit'}",
"title": "@:{'templates.generated.compat.vehicles.add_modal.title'}",
"type_load_error": "@:{'templates.generated.compat.vehicles.add_modal.type_load_error'}",
"type_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.type_placeholder'}",
"validation_error": "@:{'templates.generated.compat.vehicles.add_modal.validation_error'}"
},
"brand": "@:{'templates.generated.compat.vehicles.brand'}",
"color": "@:{'templates.generated.compat.vehicles.color'}",
"created_at": "@:{'templates.generated.compat.global.generated'}",
+3 -3
View File
@@ -3592,17 +3592,17 @@
"error_report": {
"button": "Rapporter feil",
"title": "Rapporter feil",
"subtitle": "Send de siste feildetaljene til support. Et skjermbilde legges ved når det er mulig.",
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
"before_error": "Hva gjorde du før feilen oppstod?",
"expected": "Hva forventet du at skulle skje?",
"actual": "Hva skjedde faktisk?",
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
"expected_placeholder": "Beskriv resultatet du forventet å se.",
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
"consent": "Jeg godtar at siste request-feil, Vue-feil, nettleserdetaljer, svarene mine og et app-skjermbilde når det er mulig, samles inn for feilsøking.",
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
"submit": "Send rapport",
"submitted": "Feilrapport sendt.",
"capture_failed": "Skjermbildet kunne ikke tas. Rapporten sendes uten skjermbilde.",
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
"submit_failed": "Feilrapporten kunne ikke sendes.",
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
+3 -3
View File
@@ -3642,17 +3642,17 @@
"error_report": {
"button": "Rapportera fel",
"title": "Rapportera fel",
"subtitle": "Skicka de senaste feldetaljerna till support. En skärmbild bifogas när det är möjligt.",
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
"before_error": "Vad gjorde du innan felet uppstod?",
"expected": "Vad förväntade du dig skulle hända?",
"actual": "Vad hände faktiskt?",
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
"consent": "Jag accepterar att senaste request-fel, Vue-fel, webbläsardetaljer, mina svar och en app-skärmbild när det är möjligt samlas in för felsökning.",
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
"submit": "Skicka rapport",
"submitted": "Felrapport skickad.",
"capture_failed": "Skärmbilden kunde inte tas. Rapporten skickas utan skärmbild.",
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
"submit_failed": "Felrapporten kunde inte skickas.",
"required": "Alla fält och godkännande av datainsamling krävs.",
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
@@ -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'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Rapporter fejl",
"title": "Rapporter fejl",
"subtitle": "Send de seneste fejldetaljer til support. Et skærmbillede vedhæftes, når det er muligt.",
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
"before_error": "Hvad lavede du, før fejlen opstod?",
"expected": "Hvad forventede du, der ville ske?",
"actual": "Hvad skete der faktisk?",
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
"consent": "Jeg accepterer, at de seneste request-fejl, Vue-fejl, browseroplysninger, mine svar og et app-skærmbillede, når det er muligt, indsamles til fejlfinding.",
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
"submit": "Send rapport",
"submitted": "Fejlrapporten er sendt.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Rapporten sendes uden skærmbillede.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
"submit_failed": "Fejlrapporten kunne ikke sendes.",
"required": "Alle felter og accept af dataindsamling er påkrævet.",
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
@@ -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'}",
@@ -2,20 +2,6 @@
"compat": {
"vehicles": {
"add": "@:{'terms.glossary.tilføj'} @:{'terms.glossary.køretøj'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Søg efter kundenavn eller kundenummer",
"error": "Køretøjet kunne ikke tilføjes.",
"no_customer_results": "Ingen kunder fundet",
"reference_placeholder": "Valgfri reference",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Valgt kunde",
"submit": "Tilføj køretøj",
"title": "Tilføj køretøj",
"type_load_error": "Køretøjstyper kunne ikke indlæses.",
"type_placeholder": "Vælg køretøjstype",
"validation_error": "Vælg kunde, registreringsnummer og køretøjstype."
},
"brand": "@:{'phrases.compat.admin.pos.make'}",
"color": "Farve",
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Fehler melden",
"title": "Fehler melden",
"subtitle": "Sende die letzten Fehlerdetails an den Support. Ein Screenshot wird angehängt, wenn möglich.",
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
"expected": "Was hatten Sie erwartet?",
"actual": "Was ist tatsächlich passiert?",
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
"consent": "Ich akzeptiere, dass letzte Request-Fehler, Vue-Fehler, Browserdetails, meine Antworten und ein App-Screenshot, wenn möglich, zur Fehlersuche erfasst werden.",
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
"submit": "Bericht senden",
"submitted": "Fehlerbericht gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Der Bericht wird ohne Screenshot gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
@@ -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'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send recent error details to support. A screenshot is attached when available.",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"expected_placeholder": "Describe the result you expected to see.",
"actual_placeholder": "Describe what you saw instead, including any error text.",
"consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
@@ -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'}",
@@ -2,20 +2,6 @@
"compat": {
"vehicles": {
"add": "@.capitalize:{'terms.glossary.add'} @:{'terms.glossary.vehicle'}",
"add_modal": {
"customer_label": "Customer",
"customer_placeholder": "Search by customer name or number",
"error": "Unable to add vehicle.",
"no_customer_results": "No customers found",
"reference_placeholder": "Optional reference",
"registration_placeholder": "Registration number",
"selected_customer": "Selected customer",
"submit": "Add vehicle",
"title": "Add vehicle",
"type_load_error": "Unable to load vehicle types.",
"type_placeholder": "Select vehicle type",
"validation_error": "Select a customer, registration number, and vehicle type."
},
"brand": "Brand",
"color": "Color",
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
@@ -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'}",
@@ -2,20 +2,6 @@
"vehicles": {
"actions": "@:common.actions",
"add": "@:{'phrases.compat.vehicles.add'}",
"add_modal": {
"customer_label": "@:{'phrases.compat.vehicles.add_modal.customer_label'}",
"customer_placeholder": "@:{'phrases.compat.vehicles.add_modal.customer_placeholder'}",
"error": "@:{'phrases.compat.vehicles.add_modal.error'}",
"no_customer_results": "@:{'phrases.compat.vehicles.add_modal.no_customer_results'}",
"reference_placeholder": "@:{'phrases.compat.vehicles.add_modal.reference_placeholder'}",
"registration_placeholder": "@:{'phrases.compat.vehicles.add_modal.registration_placeholder'}",
"selected_customer": "@:{'phrases.compat.vehicles.add_modal.selected_customer'}",
"submit": "@:{'phrases.compat.vehicles.add_modal.submit'}",
"title": "@:{'phrases.compat.vehicles.add_modal.title'}",
"type_load_error": "@:{'phrases.compat.vehicles.add_modal.type_load_error'}",
"type_placeholder": "@:{'phrases.compat.vehicles.add_modal.type_placeholder'}",
"validation_error": "@:{'phrases.compat.vehicles.add_modal.validation_error'}"
},
"brand": "@:{'phrases.compat.vehicles.brand'}",
"color": "@:{'phrases.compat.vehicles.color'}",
"created_at": "@:{'phrases.compat.global.generated'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Rapporter feil",
"title": "Rapporter feil",
"subtitle": "Send de siste feildetaljene til support. Et skjermbilde legges ved når det er mulig.",
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
"before_error": "Hva gjorde du før feilen oppstod?",
"expected": "Hva forventet du at skulle skje?",
"actual": "Hva skjedde faktisk?",
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
"expected_placeholder": "Beskriv resultatet du forventet å se.",
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
"consent": "Jeg godtar at siste request-feil, Vue-feil, nettleserdetaljer, svarene mine og et app-skjermbilde når det er mulig, samles inn for feilsøking.",
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
"submit": "Send rapport",
"submitted": "Feilrapport sendt.",
"capture_failed": "Skjermbildet kunne ikke tas. Rapporten sendes uten skjermbilde.",
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
"submit_failed": "Feilrapporten kunne ikke sendes.",
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Rapportera fel",
"title": "Rapportera fel",
"subtitle": "Skicka de senaste feldetaljerna till support. En skärmbild bifogas när det är möjligt.",
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
"before_error": "Vad gjorde du innan felet uppstod?",
"expected": "Vad förväntade du dig skulle hända?",
"actual": "Vad hände faktiskt?",
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
"consent": "Jag accepterar att senaste request-fel, Vue-fel, webbläsardetaljer, mina svar och en app-skärmbild när det är möjligt samlas in för felsökning.",
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
"submit": "Skicka rapport",
"submitted": "Felrapport skickad.",
"capture_failed": "Skärmbilden kunde inte tas. Rapporten skickas utan skärmbild.",
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
"submit_failed": "Felrapporten kunde inte skickas.",
"required": "Alla fält och godkännande av datainsamling krävs.",
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
-84
View File
@@ -1,84 +0,0 @@
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;
};
@@ -19,7 +19,6 @@ 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,
@@ -132,7 +131,7 @@ onMounted(() => {
return [...accessibleDepartmentIds.value];
})();
const initialDateFrom = typeof query.dateFrom === "string" ? query.dateFrom : todayLocalDateOnly();
const initialDateFrom = typeof query.dateFrom === "string" ? query.dateFrom : new Date().toISOString().split("T")[0];
const initialDateTo = typeof query.dateTo === "string" ? query.dateTo : initialDateFrom;
initializeDailyReportFilters({
@@ -171,7 +170,7 @@ watch(
);
const formatDateShort = (dateString) => {
const date = parseLocalDateOnly(dateString);
const date = new Date(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}`;
@@ -179,7 +178,7 @@ const formatDateShort = (dateString) => {
const formatDateRangeSubtitle = (startDateString, endDateString) => {
const dateOptions = { day: "2-digit", month: "2-digit", year: "2-digit" };
return `${parseLocalDateOnly(startDateString).toLocaleDateString("da-DK", dateOptions)} - ${parseLocalDateOnly(endDateString).toLocaleDateString("da-DK", dateOptions)}`;
return `${new Date(startDateString).toLocaleDateString("da-DK", dateOptions)} - ${new Date(endDateString).toLocaleDateString("da-DK", dateOptions)}`;
};
const formatSubtitle = (subtitle) => {
@@ -2,16 +2,18 @@
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 = () => {
return todayLocalDateOnly();
const date = new Date();
return date.toISOString().split("T")[0];
};
const DATE_YESTERDAY = () => {
return yesterdayLocalDateOnly();
const date = new Date();
date.setDate(date.getDate() - 1);
return date.toISOString().split("T")[0];
};
const date_shortcuts = ref({
@@ -17,7 +17,6 @@ import {
isLoading as departmentsStoreLoading,
} from "@/components/pagination/departmentTabs.vue";
import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
/**
* Props:
@@ -142,7 +141,14 @@ const toggleAllDepartments = () => {
};
const formatDateSelectionValue = (date) => {
return formatLocalDateOnly(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}`;
};
const onDateSelectionChange = (startDate, endDate) => {
@@ -155,7 +161,7 @@ const onDateSelectionChange = (startDate, endDate) => {
<div class="column is-12" data-testid="daily-report-date-controls">
<DatePeriodSelector
:on-selection-change="onDateSelectionChange"
:selection="{ startDate: parseLocalDateOnly(selected_date), endDate: parseLocalDateOnly(selected_date_to) }"
:selection="{ startDate: new Date(selected_date), endDate: new Date(selected_date_to) }"
:visibility="{
showDailySelector: true,
showWeeklySelector: true,
@@ -5,7 +5,6 @@ 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,
@@ -24,7 +23,7 @@ const created_at = ref(null);
const id = ref(null);
const message = ref('');
const yesterday = ref({
date: yesterdayLocalDateOnly(),
date: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split('T')[0],
water_usage: 0,
water_usage_morning: 0,
notes: null,
@@ -105,7 +104,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 ? parseLocalDateOnly(selected_date.value) : today).toDateString()) {
if (created_at_val.toDateString() === (selected_date.value ? new Date(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);
@@ -213,4 +212,4 @@ onChangeCall(() => {
<style scoped>
</style>
</style>
@@ -1,11 +1,10 @@
<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 formatLocalDateOnly(date_input);
return date_input.toISOString().split('T')[0];
};
date.value = formatDate(new Date());
@@ -16,7 +15,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 = parseLocalDateOnly(newDate);
timeBookingsNewScheduler.date.selected.value = new Date(newDate);
});
</script>
@@ -64,4 +63,4 @@ watch(date, (newDate) => {
<style scoped>
</style>
</style>
@@ -1,7 +1,6 @@
<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: {
@@ -69,7 +68,8 @@ const getBookings = async () => {
};
const getDate = () => {
return todayLocalDateOnly();
const date = new Date();
return date.toISOString().split('T')[0];
};
// Get the department
@@ -2,7 +2,6 @@
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']);
@@ -17,12 +16,12 @@ const selectDate = (startIso, endIso) => {
<template>
<DatePeriodSelector
:on-selection-change="(startDate, endDate) => selectDate(formatLocalDateOnly(startDate), formatLocalDateOnly(endDate))"
:selection="{ startDate: parseLocalDateOnly(selected_date), endDate: parseLocalDateOnly(selected_date_to) }"
: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) }"
:visibility="{ showDailySelector: true, showWeeklySelector: true, showMultipleMonthWarning: false, showUpdateButton: false }"
:reverse-level-order="true"
@update:selection="(newSelection) => {
selectDate(formatLocalDateOnly(newSelection.startDate), formatLocalDateOnly(newSelection.endDate));
selectDate(newSelection.startDate.toISOString().split('T')[0], newSelection.endDate.toISOString().split('T')[0]);
}"
/>
</template>
@@ -1,6 +1,5 @@
<script lang="ts">
import {defineComponent, ref, watch} from 'vue';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
type fetch = {
@@ -12,8 +11,8 @@ type fetch = {
};
/** Define the variables */
export const show_this_week = ref(false);
export const selected_date = ref(todayLocalDateOnly());
export const selected_date_to = ref(todayLocalDateOnly());
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 last_fetch_id = ref(0);
export const fetches = ref<fetch[]>([]);
/** Define the functions */
@@ -54,4 +53,4 @@ watch([selected_date, selected_date_to], () => {
export default defineComponent({
name: 'DepartmentsOverviewObject'
});
</script>
</script>
@@ -1,53 +1,21 @@
<script setup>
import { ref } from "vue";
import { getOrders } from "@/components/shop/Orders.vue";
import { showCreateOrderForm } from "@/components/forms/superUser/createOrderForm.vue";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import Orders from "@/components/displays/Orders.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import VehiclesPagination from "@/components/displays/pagination/models/UserDashboard/VehiclesPagination.vue";
import SuperuserAddVehicleModal from "@/components/forms/superUser/SuperuserAddVehicleModal.vue";
import { loadList } from "@/components/pagination/paginatedList.vue";
const isAddVehicleModalOpen = ref(false);
const openAddVehicleModal = () => {
isAddVehicleModalOpen.value = true;
};
const closeAddVehicleModal = () => {
isAddVehicleModalOpen.value = false;
};
const handleVehicleCreated = async () => {
closeAddVehicleModal();
await loadList();
};
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<SuperUserDashboardNavigation />
<VehiclesPagination>
<template #actions>
<button
class="button is-link button-same-width superuser-vehicles__add-button"
type="button"
data-testid="superuser-vehicles-add"
@click="openAddVehicleModal"
>
{{ $t("vehicles.add") }}
</button>
</template>
</VehiclesPagination>
<SuperuserAddVehicleModal
v-if="isAddVehicleModalOpen"
@close="closeAddVehicleModal"
@created="handleVehicleCreated"
/>
<VehiclesPagination />
</RestrictedPageWrapper>
</template>
<style scoped>
.superuser-vehicles__add-button {
white-space: nowrap;
}
</style>
</style>
@@ -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,7 +13,6 @@ 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();
@@ -71,8 +70,7 @@ getDepartments()
// Get the statistics
const getStatistics = async () => {
// Get the statistics
const today = todayLocalDateOnly();
const response = await authenticatedRequest('/statistics/income/departments?start_date=' + today + '&end_date=' + today)
const response = await authenticatedRequest('/statistics/income/departments?start_date=' + (new Date()).toISOString().split('T')[0] + '&end_date=' + (new Date()).toISOString().split('T')[0])
// Get the sent data
statistics_department_today.value = response.data.data
// Log the data
@@ -133,4 +131,4 @@ getStatistics()
<style scoped>
</style>
</style>
@@ -4,7 +4,6 @@ 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)
@@ -17,7 +16,7 @@ const chartOptions = {
// Shortcut to statistics
// Set the time for the statistics to today
SessionUser.adminUser.statistics.set_time(todayLocalDateOnly(), todayLocalDateOnly())
SessionUser.adminUser.statistics.set_time((new Date()).toISOString().split('T')[0], (new Date()).toISOString().split('T')[0])
@@ -34,4 +33,4 @@ SessionUser.adminUser.statistics.set_time(todayLocalDateOnly(), todayLocalDateOn
}"
/>
</div>
</template>
</template>
+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 });
+4 -152
View File
@@ -15,20 +15,16 @@ function isApiUrl(url: string) {
);
}
const html2CanvasModulePattern = /\/(?:node_modules\/.*)?html2canvas(?:\.[\w-]+)?\.js(?:\?.*)?$/i;
async function mockHtml2Canvas(page, { alwaysFail = false } = {}) {
await page.unroute(html2CanvasModulePattern).catch(() => {});
await page.route(html2CanvasModulePattern, async (route) => {
async function mockHtml2Canvas(page) {
await page.route(/\/node_modules\/html2canvas\/dist\/html2canvas\.esm\.js(?:\?.*)?$/i, async (route) => {
await route.fulfill({
contentType: "text/javascript",
body: `
const alwaysFail = ${alwaysFail ? "true" : "false"};
let html2canvasAttempts = 0;
export default async function html2canvas() {
html2canvasAttempts += 1;
if (alwaysFail || html2canvasAttempts === 1) {
throw new Error(alwaysFail ? "Simulated permanent capture failure" : "Simulated first capture failure");
if (html2canvasAttempts === 1) {
throw new Error("Simulated first capture failure");
}
const canvas = document.createElement("canvas");
canvas.width = 8;
@@ -167,68 +163,10 @@ test.describe("Authenticated error reports", () => {
expect(Array.isArray(body.vue_errors)).toBe(true);
expect(body.context).toMatchObject({
data_collection_policy_version: "error-report-v1",
screenshot_attachment: {
status: "stored",
attached: true,
},
});
await expect(page.getByTestId("error-report-submitted")).toBeVisible();
});
test("submits without a screenshot when capture never succeeds", async ({ page }) => {
await mockHtml2Canvas(page, { alwaysFail: true });
const submittedBodies: Array<Record<string, unknown>> = [];
await page.route(/\/error-reports(?:\?.*)?$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
if (route.request().method().toUpperCase() !== "POST") {
await route.fallback();
return;
}
const body = route.request().postDataJSON() as Record<string, unknown>;
submittedBodies.push(body);
await route.fulfill(
json(
{
success: true,
data: {
id: 100,
status: "open",
screenshot: null,
},
},
201
)
);
});
await primeMockSession(page, { token: "error-report-user-token", bootPath: "/admin" });
await openErrorReportLauncher(page);
await page.getByLabel("What were you doing before the error occurred?").fill("Opening the orders page");
await page.getByLabel("What did you expect would happen?").fill("The orders should load");
await page.getByLabel("What actually happened?").fill("The page showed an error");
await page.getByTestId("error-report-consent").check();
await page.getByTestId("error-report-submit").click();
await expect.poll(() => submittedBodies.length).toBe(1);
const body = submittedBodies[0];
expect(body.screenshot).toBeNull();
expect(body.context).toMatchObject({
screenshot_attachment: {
status: "capture_failed",
attached: false,
},
});
await expect(page.getByTestId("error-report-capture-warning")).toContainText(
"The screen capture failed. The report will be sent without a screenshot."
);
await expect(page.getByTestId("error-report-submitted")).toBeVisible();
});
test("lets superusers inspect and resolve submitted reports", async ({ page }) => {
const report: Record<string, any> = {
id: 12,
@@ -339,90 +277,4 @@ test.describe("Authenticated error reports", () => {
]);
await expect(page.getByTestId("error-report-detail")).toContainText("resolved");
});
test("lets superusers inspect submitted reports without screenshots", async ({ page }) => {
const report: Record<string, any> = {
id: 13,
status: "open",
reporter: {
type: "user",
user_id: 7,
customer_number: 12345,
name: "Error Reporter",
email: "reporter@example.test",
},
route_path: "/user/orders",
page_url: "https://app.example.test/user/orders",
release_trace_id: "trace-no-screenshot",
frontend_version: "front-1",
api_version: "api-1",
screenshot: null,
answers: {
before_error: "Opening the orders page",
expected: "Orders should load",
actual: "The table stayed empty",
},
request_error_count: 0,
vue_error_count: 0,
request_errors: [],
vue_errors: [],
runtime_context: {
screenshot_attachment: {
status: "capture_failed",
attached: false,
mime_type: null,
size_bytes: 0,
},
},
created_at: "2026-05-19 08:00:00",
updated_at: "2026-05-19 08:00:00",
resolved_at: null,
resolved_by_user_id: null,
resolution_note: null,
};
await page.route(/\/superuser\/error-reports(?:\?.*)?$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
if (route.request().method().toUpperCase() !== "GET") {
await route.fallback();
return;
}
await route.fulfill(
json({
success: true,
data: {
items: [report],
counts: {
open: 1,
resolved: 0,
all: 1,
},
limit: 100,
offset: 0,
},
})
);
});
await page.route(/\/superuser\/error-reports\/13$/i, async (route) => {
if (!isApiUrl(route.request().url())) {
await route.fallback();
return;
}
await route.fulfill(json({ success: true, data: report }));
});
await primeMockSession(page, { token: "error-report-superuser-token", bootPath: "/superuser/error-reports" });
await expect(page.getByTestId("error-reports-page")).toBeVisible();
await expect(page.getByTestId("error-report-list")).toContainText("/user/orders");
await page.getByTestId("error-report-view").click();
const detail = page.getByTestId("error-report-detail");
await expect(detail).toContainText("Opening the orders page");
await expect(detail).toContainText("trace-no-screenshot");
await expect(detail.locator("img")).toHaveCount(0);
});
});
-60
View File
@@ -3272,66 +3272,6 @@ test.describe("POS mobile order flow", () => {
expect(createdProductIds).toEqual([53, 71, 91]);
});
test("manual step 2 blocks additional items when customer restricts additional services", async ({ page }) => {
const orderId = 9414;
const fixture = createMobilePosFixture({
customerAttributesByNumber: {
[REGULAR_CUSTOMER_ID]: [
{
id: 941401,
customer_number: REGULAR_CUSTOMER_ID,
attribute: "restrictAdditionalServices",
},
],
},
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STEP2-RESTRICT-ADDITIONAL",
reg_1: "ZZ00000",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-restrict-additional-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-RESTRICT-ADDITIONAL",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect.poll(() => fixture.requestCounters.customerAttributesGet, { timeout: 10_000 }).toBeGreaterThan(0);
await selectPrimaryProduct(page, 53);
await expect(page.getByTestId("pos-mobile-additional-items-open")).toHaveCount(1);
await page.getByTestId("pos-mobile-additional-items-open").click();
await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0);
await longPressAdditionalItems(page);
await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0);
await expect
.poll(
async () => {
const snapshot = await getStoredPosSnapshot(page);
return snapshot?.transactionItems?.additionalItems || [];
},
{ timeout: 10_000 }
)
.toEqual([]);
});
test("clear all deletes the order and returns to the scanner", async ({ page }) => {
const orderId = 9403;
const fixture = createMobilePosFixture({
+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,
});
});
});
@@ -7,13 +7,10 @@ async function primeSuperuserSession(page) {
}
test.describe("Superuser vehicles smoke", () => {
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
pos: true,
});
await primeSuperuserSession(page);
});
@@ -26,51 +23,4 @@ test.describe("Superuser vehicles smoke", () => {
await expect(page.locator("body")).toContainText(/registrerede|registered/i);
await expect(page.locator("body")).not.toContainText(/Order ID is required/i);
});
test("superuser can add a vehicle after selecting a customer from searchable results", async ({ page }) => {
const createVehiclePayloads = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname.endsWith("/vehicles") && request.method() === "POST") {
createVehiclePayloads.push(request.postDataJSON());
}
});
await page.goto("/superuser/vehicles");
await page.getByTestId("superuser-vehicles-add").click();
await expect(page.getByTestId("superuser-add-vehicle-modal")).toBeVisible();
await page.getByTestId("superuser-add-vehicle-customer-input").fill("12345679");
await expect(page.getByTestId("superuser-add-vehicle-customer-option-0")).toBeVisible();
await page.getByTestId("superuser-add-vehicle-customer-option-0").click();
await expect(page.getByTestId("superuser-add-vehicle-customer-selected")).toContainText("#12345679");
await page.getByTestId("superuser-add-vehicle-registration").fill("ab12345");
await expect(page.getByTestId("superuser-add-vehicle-type")).toBeEnabled();
await page.getByTestId("superuser-add-vehicle-type").selectOption("53");
await page.getByTestId("superuser-add-vehicle-wash-subscription").check();
await page.getByTestId("superuser-add-vehicle-reference").fill("Fleet reference");
await Promise.all([
page.waitForResponse(
(response) => response.url().includes("/vehicles") && response.request().method() === "POST"
),
page.getByTestId("superuser-add-vehicle-submit").click(),
]);
expect(createVehiclePayloads).toEqual([
{
type: 53,
reg: "AB12345",
wash_subscription: true,
customer_id: 12345679,
reference: "Fleet reference",
},
]);
await expect(page.getByTestId("superuser-add-vehicle-modal")).toBeHidden();
await expect(page.getByTestId("user-vehicles-table")).toContainText("AB12345");
});
});
-25
View File
@@ -2736,7 +2736,6 @@ export function createPosFixture(overrides = {}) {
last_order_id: 54518,
},
],
nextVehicleId: 7002,
unknownVehicles: [],
orderBookings: [],
bookingOrderAssignments: [],
@@ -3279,30 +3278,6 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
return true;
}
if (pathname.endsWith("/vehicles") && method === "POST") {
const body = request.postDataJSON?.() || {};
const customerId = Number(body.customer_id || 0);
const customer = posFixture.customersByNumber[customerId] || null;
const vehicle = {
id: posFixture.nextVehicleId || 9001,
reg: String(body.reg || "").toUpperCase(),
customer_id: customerId,
customer_name: customer?.name || "",
type: Number(body.type || 0),
status: "verified",
barred: false,
wash_subscription: Boolean(body.wash_subscription),
addons: { enabled: 0, available: 0, list: [] },
reference: body.reference || null,
};
posFixture.nextVehicleId = vehicle.id + 1;
posFixture.vehicles = [vehicle, ...(posFixture.vehicles || [])];
await route.fulfill(json({ success: true, data: vehicle }));
return true;
}
if (pathname.endsWith("/department/vehicles/unknown-customer") && method === "GET") {
await route.fulfill(json({ success: true, data: posFixture.unknownVehicles || [] }));
return true;
-115
View File
@@ -1,115 +0,0 @@
// @vitest-environment jsdom
import { nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const searchMocks = vi.hoisted(() => ({
isSearchingRef: null,
resultsRef: null,
searchCustomer: vi.fn(),
}));
vi.mock("@/components/search/economic/customerSearch.vue", async () => {
const { ref } = await vi.importActual("vue");
searchMocks.isSearchingRef = ref(false);
searchMocks.resultsRef = ref([]);
return {
isSearching: searchMocks.isSearchingRef,
searchCustomerResults: searchMocks.resultsRef,
searchCustomer: searchMocks.searchCustomer,
};
});
import CustomerSearchSelect from "@/components/search/economic/CustomerSearchSelect.vue";
const customers = [
{
customerNumber: 12345679,
name: "Acme Transport",
city: "Taastrup",
},
{
customerNumber: 87654321,
name: "Nordic Wash",
city: "Copenhagen",
},
];
const messages = {
en: {
vehicles: {
add_modal: {
customer_label: "Customer",
customer_placeholder: "Search customers",
selected_customer: "Selected customer",
},
},
common: {
clear: "Clear",
},
},
};
const mountComponent = (props = {}) =>
mountWithApp(CustomerSearchSelect, {
props: {
inputId: "test-customer-search",
testIdPrefix: "test-customer",
...props,
},
messages,
});
describe("CustomerSearchSelect", () => {
beforeEach(() => {
vi.clearAllMocks();
searchMocks.isSearchingRef.value = false;
searchMocks.resultsRef.value = [];
searchMocks.searchCustomer.mockImplementation((query) => {
searchMocks.resultsRef.value = query ? customers : [];
return Promise.resolve(searchMocks.resultsRef.value);
});
});
it("searches customers and emits the selected customer", async () => {
const wrapper = mountComponent();
await wrapper.get('[data-testid="test-customer-input"]').setValue("acme");
await nextTick();
expect(searchMocks.searchCustomer).toHaveBeenLastCalledWith("acme");
expect(wrapper.get('[data-testid="test-customer-option-0"]').text()).toContain("Acme Transport");
await wrapper.get('[data-testid="test-customer-option-0"]').trigger("mousedown");
await nextTick();
expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([customers[0]]);
expect(wrapper.emitted("selected").at(-1)).toEqual([customers[0]]);
await wrapper.setProps({ modelValue: customers[0] });
await nextTick();
expect(wrapper.get('[data-testid="test-customer-selected"]').text()).toContain("Acme Transport");
});
it("supports keyboard selection and clearing", async () => {
const wrapper = mountComponent();
await wrapper.get('[data-testid="test-customer-input"]').setValue("nordic");
await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "ArrowDown" });
await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "ArrowDown" });
await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "Enter" });
await nextTick();
expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([customers[1]]);
await wrapper.setProps({ modelValue: customers[1] });
await nextTick();
await wrapper.get('[data-testid="test-customer-clear"]').trigger("click");
await nextTick();
expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([null]);
expect(wrapper.emitted("cleared")).toHaveLength(1);
});
});
-34
View File
@@ -1,34 +0,0 @@
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,32 +122,6 @@ 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,7 +2,6 @@
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(() => ({
@@ -40,10 +39,10 @@ const DatePeriodSelectorStub = defineComponent({
emits: ["update:selection"],
template: `
<div>
<button class="trigger-on-selection-change" @click="onSelectionChange(new Date(2026, 2, 24), new Date(2026, 2, 29))">
<button class="trigger-on-selection-change" @click="onSelectionChange(new Date('2026-03-24T00:00:00.000Z'), new Date('2026-03-29T00:00:00.000Z'))">
onSelectionChange
</button>
<button class="trigger-update-selection" @click="$emit('update:selection', { startDate: new Date(2026, 3, 1), endDate: new Date(2026, 3, 30) })">
<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') })">
update:selection
</button>
</div>
@@ -106,7 +105,7 @@ describe("DepartmentDashboardOverviewNavigation", () => {
});
const selector = wrapper.findComponent(DatePeriodSelectorStub);
expect(formatLocalDateOnly(selector.props("selection").startDate)).toBe("2026-05-05");
expect(formatLocalDateOnly(selector.props("selection").endDate)).toBe("2026-05-11");
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");
});
});
+1
View File
@@ -579,6 +579,7 @@ describe("MyWashStart", () => {
afterEach(() => {
consoleWarnSpy?.mockRestore();
vi.clearAllTimers();
vi.useRealTimers();
localStorage.clear();
});
@@ -1,110 +0,0 @@
// @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");
+17 -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,8 +48,8 @@ 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);
});
+1
View File
@@ -95,6 +95,7 @@ afterEach(async () => {
await Promise.resolve();
await Promise.resolve();
vi.clearAllTimers();
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllGlobals();
@@ -1,152 +0,0 @@
// @vitest-environment jsdom
import { nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const sessionMocks = vi.hoisted(() => ({
addVehicle: vi.fn(),
parseErrorMessage: vi.fn(),
vehicleTypeOptions: vi.fn(),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => {
const sessionUser = {
functions: {
parseErrorMessage: sessionMocks.parseErrorMessage,
},
objects: {
vehicles: {
add: sessionMocks.addVehicle,
columns: {
type: {
options: sessionMocks.vehicleTypeOptions,
},
},
},
},
};
return {
SessionUser: sessionUser,
default: sessionUser,
};
});
import SuperuserAddVehicleModal from "@/components/forms/superUser/SuperuserAddVehicleModal.vue";
const CustomerSearchSelectStub = {
props: ["modelValue"],
emits: ["update:modelValue"],
template: `
<button
type="button"
data-testid="customer-select-stub"
@click="$emit('update:modelValue', { customerNumber: 12345679, name: 'Acme Transport' })"
>
Select customer
</button>
`,
};
const messages = {
en: {
vehicles: {
add_modal: {
customer_label: "Customer",
customer_placeholder: "Search customers",
error: "Unable to add vehicle.",
reference_placeholder: "Optional reference",
registration_placeholder: "Registration number",
selected_customer: "Selected customer",
submit: "Add vehicle",
title: "Add vehicle",
type_load_error: "Unable to load vehicle types.",
type_placeholder: "Select vehicle type",
validation_error: "Select required fields.",
},
form: {
license_plate: "Registration",
type: "Type",
},
},
objects: {
vehicles: {
columns: {
wash_subscription: "Wash subscription",
},
},
},
common: {
cancel: "Cancel",
close: "Close",
reference: "Reference",
},
},
};
const flushAll = async () => {
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
};
const mountComponent = () =>
mountWithApp(SuperuserAddVehicleModal, {
messages,
global: {
stubs: {
CustomerSearchSelect: CustomerSearchSelectStub,
},
},
});
describe("SuperuserAddVehicleModal", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionMocks.vehicleTypeOptions.mockResolvedValue([
{
id: 53,
name: "Forvogn",
},
]);
sessionMocks.addVehicle.mockResolvedValue({
data: {
data: {
id: 7002,
},
},
});
sessionMocks.parseErrorMessage.mockReturnValue(null);
});
it("submits the selected customer and vehicle fields through the vehicle API", async () => {
const wrapper = mountComponent();
await flushAll();
await wrapper.get('[data-testid="customer-select-stub"]').trigger("click");
await wrapper.get('[data-testid="superuser-add-vehicle-registration"]').setValue("ab12345");
await wrapper.get('[data-testid="superuser-add-vehicle-type"]').setValue("53");
await wrapper.get('[data-testid="superuser-add-vehicle-wash-subscription"]').setValue(true);
await wrapper.get('[data-testid="superuser-add-vehicle-reference"]').setValue("Fleet ref");
await wrapper.get('[data-testid="superuser-add-vehicle-submit"]').trigger("click");
await flushAll();
expect(sessionMocks.addVehicle).toHaveBeenCalledWith(53, "AB12345", true, 12345679, "Fleet ref");
expect(wrapper.emitted("created")).toHaveLength(1);
});
it("keeps submit disabled until required fields are present", async () => {
const wrapper = mountComponent();
await flushAll();
expect(wrapper.get('[data-testid="superuser-add-vehicle-submit"]').attributes("disabled")).toBeDefined();
await wrapper.get('[data-testid="customer-select-stub"]').trigger("click");
await wrapper.get('[data-testid="superuser-add-vehicle-registration"]').setValue("AB12345");
await wrapper.get('[data-testid="superuser-add-vehicle-type"]').setValue("53");
await nextTick();
expect(wrapper.get('[data-testid="superuser-add-vehicle-submit"]').attributes("disabled")).toBeUndefined();
});
});
@@ -367,8 +367,6 @@ 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);');