Add responsive tests for "/qr/new-customer" layout, extend API with split_by_month logic, and update E2E/admin tests with session mocking and department sync fixes. Normalize POS department handling, improve invoicing period split logic, and enhance receipt export safety.
@@ -86,6 +86,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowBookingCompletion: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
department_lane_id: {
|
||||
type: Number,
|
||||
default: null,
|
||||
@@ -1211,20 +1215,46 @@ const openPrimaryGatewayPage = async () => {
|
||||
SessionUser.functions.redirectTo.superUser("/selfserve/edge-agents", true);
|
||||
};
|
||||
|
||||
const showCompleteOrderBookingConfirmation = async () => {
|
||||
const result = await Swal.fire({
|
||||
title: t("bookings.complete_booking"),
|
||||
text: t("pos.confirm_complete_order"),
|
||||
icon: "warning",
|
||||
showDenyButton: true,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t("admin.pos.settings_wheel.view_booking_new_tab"),
|
||||
denyButtonText: t("tables.bookings.complete_wash_without_certificate"),
|
||||
cancelButtonText: t("global.cancel"),
|
||||
reverseButtons: true,
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
SessionUser.functions.redirectTo.department(
|
||||
props.department_id,
|
||||
"modules/bookings/order/" + props.order_booking_id,
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.isDenied) {
|
||||
await SessionUser.objects.order_bookings.functions.complete(props.order_booking_id, null, () => {
|
||||
props.refreshFunction();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const flatBuiltInMenuSections = computed(() => {
|
||||
const sections = [];
|
||||
|
||||
if (props.order_booking_id) {
|
||||
const bookingSection = buildMenuSection("booking", t("admin.pos.settings_wheel.booking"), [
|
||||
(SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) && !props.order_id
|
||||
props.allowBookingCompletion
|
||||
? buildMenuAction("booking-complete", {
|
||||
icon: "fas fa-check-circle",
|
||||
icon: "fas fa-check",
|
||||
label: t("admin.pos.settings_wheel.mark_as_completed"),
|
||||
template: "success",
|
||||
clickAction: () =>
|
||||
SessionUser.objects.order_bookings.functions.showCompleteConfirmationModal(props.order_booking_id, () => {
|
||||
props.refreshFunction();
|
||||
}),
|
||||
clickAction: showCompleteOrderBookingConfirmation,
|
||||
})
|
||||
: null,
|
||||
buildMenuAction("booking-view", {
|
||||
|
||||
@@ -119,9 +119,25 @@ const formatDateInputValue = (date: Date) => {
|
||||
return "";
|
||||
}
|
||||
|
||||
return date.toISOString().split("T")[0];
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const parseDateInputValue = (value: string) => {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match) {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
};
|
||||
|
||||
const isSameDateInputValue = (left: Date, right: Date) => (
|
||||
formatDateInputValue(left) === formatDateInputValue(right)
|
||||
);
|
||||
|
||||
const updateSelection = () => {
|
||||
emits("update:selection", { startDate: startDate.value, endDate: endDate.value });
|
||||
props.onSelectionChange(startDate.value, endDate.value);
|
||||
@@ -177,25 +193,38 @@ const isSelectionValid = computed(() => (
|
||||
));
|
||||
|
||||
const handleStartDateChange = (event) => {
|
||||
const newDate = new Date(event.target.value);
|
||||
const newDate = parseDateInputValue(event.target.value);
|
||||
if (Number.isNaN(newDate.getTime())) {
|
||||
console.warn("Invalid start date:", event.target.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSameDateInputValue(newDate, startDate.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyDateRange(newDate, endDate.value);
|
||||
};
|
||||
|
||||
const handleEndDateChange = (event) => {
|
||||
const newDate = new Date(event.target.value);
|
||||
const newDate = parseDateInputValue(event.target.value);
|
||||
if (Number.isNaN(newDate.getTime())) {
|
||||
console.warn("Invalid end date:", event.target.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSameDateInputValue(newDate, endDate.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyDateRange(startDate.value, newDate);
|
||||
};
|
||||
|
||||
const getFullMonthRange = (year: number, month: number): DateRange => ({
|
||||
startDate: new Date(year, month - 1, 1, 0, 0, 0, 0),
|
||||
endDate: new Date(year, month, 0, 23, 59, 59, 999),
|
||||
});
|
||||
|
||||
const setMonth = (month: number) => {
|
||||
if (month < 1 || month > 12) {
|
||||
console.warn("Invalid month selection:", month);
|
||||
@@ -203,9 +232,8 @@ const setMonth = (month: number) => {
|
||||
}
|
||||
|
||||
const year = startDate.value.getFullYear();
|
||||
const startOfMonth = new Date(year, month - 1, 1);
|
||||
const endOfMonth = new Date(year, month, 0);
|
||||
applyDateRange(startOfMonth, endOfMonth);
|
||||
const range = getFullMonthRange(year, month);
|
||||
applyDateRange(range.startDate, range.endDate);
|
||||
};
|
||||
|
||||
const handleMonthChange = (event) => {
|
||||
@@ -217,6 +245,10 @@ const handleMonthChange = (event) => {
|
||||
setMonth(month);
|
||||
};
|
||||
|
||||
const setCurrentSelectionEntireMonth = () => {
|
||||
setMonth(currentMonthSelection.value);
|
||||
};
|
||||
|
||||
const handleYearChange = (event) => {
|
||||
const year = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(year)) {
|
||||
@@ -307,24 +339,14 @@ const shortcuts = computed<Shortcut[]>(() => {
|
||||
label: SessionUser.objects.global.language.text.this_month,
|
||||
getRange: () => {
|
||||
const today = new Date();
|
||||
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return {
|
||||
startDate: new Date(firstDayOfThisMonth.setHours(23, 59, 59, 999)),
|
||||
endDate: new Date(lastDayOfThisMonth.setHours(23, 59, 59, 999)),
|
||||
};
|
||||
return getFullMonthRange(today.getFullYear(), today.getMonth() + 1);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.last_month,
|
||||
getRange: () => {
|
||||
const today = new Date();
|
||||
const firstDayOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
const lastDayOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
return {
|
||||
startDate: new Date(firstDayOfLastMonth.setHours(23, 59, 59, 999)),
|
||||
endDate: new Date(lastDayOfLastMonth.setHours(23, 59, 59, 999)),
|
||||
};
|
||||
return getFullMonthRange(today.getFullYear(), today.getMonth());
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -346,16 +368,7 @@ const shortcuts = computed<Shortcut[]>(() => {
|
||||
label: SessionUser.objects.global.language.text.same_month_last_year,
|
||||
getRange: () => {
|
||||
const today = new Date();
|
||||
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
const firstDayOfLastYearSameMonth = new Date(firstDayOfThisMonth);
|
||||
firstDayOfLastYearSameMonth.setFullYear(firstDayOfThisMonth.getFullYear() - 1);
|
||||
const lastDayOfLastYearSameMonth = new Date(lastDayOfThisMonth);
|
||||
lastDayOfLastYearSameMonth.setFullYear(lastDayOfThisMonth.getFullYear() - 1);
|
||||
return {
|
||||
startDate: new Date(firstDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
|
||||
endDate: new Date(lastDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
|
||||
};
|
||||
return getFullMonthRange(today.getFullYear() - 1, today.getMonth() + 1);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -419,6 +432,7 @@ const handleShortcutSelection = (event) => {
|
||||
class="input"
|
||||
data-testid="date-period-start"
|
||||
:value="formatDateInputValue(startDate)"
|
||||
@input="handleStartDateChange"
|
||||
@change="handleStartDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
@@ -434,6 +448,7 @@ const handleShortcutSelection = (event) => {
|
||||
class="input"
|
||||
data-testid="date-period-end"
|
||||
:value="formatDateInputValue(endDate)"
|
||||
@input="handleEndDateChange"
|
||||
@change="handleEndDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
@@ -516,6 +531,7 @@ const handleShortcutSelection = (event) => {
|
||||
class="input"
|
||||
data-testid="date-period-start"
|
||||
:value="formatDateInputValue(startDate)"
|
||||
@input="handleStartDateChange"
|
||||
@change="handleStartDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
@@ -530,6 +546,7 @@ const handleShortcutSelection = (event) => {
|
||||
class="input"
|
||||
data-testid="date-period-end"
|
||||
:value="formatDateInputValue(endDate)"
|
||||
@input="handleEndDateChange"
|
||||
@change="handleEndDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
@@ -648,7 +665,7 @@ const handleShortcutSelection = (event) => {
|
||||
iconPack="fas"
|
||||
has-icon
|
||||
>
|
||||
Selection is not an entire month <span class="has-text-weight-bold is-clickable" @click="setMonth(currentMonthSelection)">Click to set entire month</span>.
|
||||
Selection is not an entire month <span class="has-text-weight-bold is-clickable" data-testid="date-period-set-entire-month" @click="setCurrentSelectionEntireMonth">Click to set entire month</span>.
|
||||
When a partial month selection is made, it may not accurately represent the intended time period. Consider selecting an entire month for more precise results.
|
||||
</b-message>
|
||||
</div>
|
||||
|
||||
@@ -413,6 +413,7 @@ const closeModal = () => {
|
||||
:refreshFunction="emitRefreshBookings"
|
||||
:icon="'fas fa-ellipsis-v'"
|
||||
trigger-button-variant="text"
|
||||
allow-booking-completion
|
||||
>
|
||||
<template #actions />
|
||||
</ActionSettingsWheelButton>
|
||||
|
||||
@@ -320,16 +320,23 @@ const noop = () => {};
|
||||
|
||||
.vehicle-card__media {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
border: 1px solid rgba(31, 91, 183, 0.12);
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
flex: 0 0 5.25rem;
|
||||
height: 5.25rem;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
width: 5.25rem;
|
||||
}
|
||||
|
||||
.vehicle-card__image {
|
||||
height: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.vehicle-card__details {
|
||||
|
||||
@@ -407,6 +407,7 @@ const onClick = async () => {
|
||||
customerId: getCustomerId(),
|
||||
departmentId: POSDepartmentProcess.getDepartment(),
|
||||
allowCompleted: false,
|
||||
syncDepartment: true,
|
||||
});
|
||||
if (restoredOrderId) {
|
||||
console.warn("Order ID retrieved from local storage:", restoredOrderId);
|
||||
|
||||
@@ -120,13 +120,6 @@ const completeWithCertificate = async () => {
|
||||
Generer vaskecertifikat
|
||||
</button>
|
||||
</div>
|
||||
<!--<FormDisplay
|
||||
:form_identifier="'GENERATE_BOOKING_WASH_CERTIFICATE'"
|
||||
v-bind:validator_options="{validateBookingId: {default: metadata.getBookingId(), locked: true, visible: false}}"
|
||||
:onSuccessfulSubmission="onSuccessfulCertificateSubmission"
|
||||
:showSuccessSubmitAlert="false"
|
||||
:showResetButton="false"
|
||||
/>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -355,6 +355,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
||||
:refreshFunction="emitRefreshBookings"
|
||||
:icon="'fas fa-ellipsis-v'"
|
||||
trigger-button-variant="text"
|
||||
allow-booking-completion
|
||||
>
|
||||
<template #actions />
|
||||
</ActionSettingsWheelButton>
|
||||
|
||||
@@ -93,6 +93,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
{{ $t("customer_creation.customer.submit") }}
|
||||
</LoadButtonWhileAwait>
|
||||
<slot name="additionalButtons"></slot>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ const department_booking_counts_loading = ref(false);
|
||||
const department_draft_count = ref(0);
|
||||
const department_draft_count_loading = ref(false);
|
||||
const draftTransactionCustomerNumber = computed(() => getDraftTransactionCustomerNumber());
|
||||
const canUseAdminNavigationCountsValue = computed(() => canUseAdminNavigationCounts());
|
||||
let bookingCountRequestId = 0;
|
||||
let draftCountRequestId = 0;
|
||||
let bookingCountFetchInFlight = false;
|
||||
@@ -328,7 +329,7 @@ const isDepartmentSet = computed(() => {
|
||||
return department_id.value !== 'default' && parseInt(department_id.value) > 0;
|
||||
});
|
||||
|
||||
watch(department_id, () => {
|
||||
watch([department_id, canUseAdminNavigationCountsValue], () => {
|
||||
refreshNavigationCounts({
|
||||
showBookingLoadingIndicator: true,
|
||||
showDraftLoadingIndicator: true,
|
||||
|
||||
@@ -749,8 +749,10 @@ export const SessionUser = {
|
||||
// If
|
||||
// If the current path is not an admin path, redirect to the department page
|
||||
const newPath = currentPath.replace(/\/admin\/\d+/, '').replace(/^\/admin\/?/, '').replace(/^\//, '');
|
||||
const shouldPreserveCurrentQuery = newPath === 'modules/pos' || newPath.startsWith('modules/pos/');
|
||||
const queryString = shouldPreserveCurrentQuery ? window.location.search : '';
|
||||
// Redirect to the department page
|
||||
SessionUser.functions.redirectTo.department(departmentId, newPath);
|
||||
SessionUser.functions.redirectTo.department(departmentId, newPath + queryString);
|
||||
},
|
||||
/** Show confirm logout dialog */
|
||||
showConfirmLogoutDialog: () => {
|
||||
|
||||
@@ -440,6 +440,19 @@ export const CollectedOrderInvoices = {
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
split_by_month: async (dateFrom, dateTo, options = {}) => {
|
||||
return authenticatedRequest('/collected-invoices/split-by-month', 'POST', {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
...(options.preview !== undefined ? { preview: !!options.preview } : {}),
|
||||
}).then((response) => {
|
||||
console.log(response);
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
showCreateNewCustom: showCreateCustomInvoiceCollectionForm,
|
||||
showInvoiceCollectionPickerForm: showInvoiceCollectionPickerModal,
|
||||
add_vehicle_subscriptions: async (id) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script>
|
||||
import Swal from "sweetalert2";
|
||||
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { getDepartmentName } from "@/components/pagination/departmentTabs.vue";
|
||||
@@ -343,42 +342,6 @@ export const OrderBookings = {
|
||||
{ safety_seal: normalizedSafetySeal === "" ? null : normalizedSafetySeal },
|
||||
onAfterComplete
|
||||
);
|
||||
},
|
||||
showCompleteConfirmationModal: async (id, onAfterComplete = null) => {
|
||||
// Show a confirmation modal with input for safety seal
|
||||
Swal.fire({
|
||||
title: `Mark order booking #${id} as completed`,
|
||||
text: "Are you sure you want to mark this order booking as completed? Please enter the safety seal number to confirm.",
|
||||
input: 'text',
|
||||
inputLabel: 'Safety Seal Number',
|
||||
inputPlaceholder: 'Enter safety seal number',
|
||||
showCancelButton: true,
|
||||
showDenyButton: true, // Used when no safety seal is available
|
||||
denyButtonText: 'No Safety Seal',
|
||||
confirmButtonText: 'Complete',
|
||||
inputValidator: (value) => {
|
||||
if (!value) {
|
||||
return 'You need to write something!';
|
||||
}
|
||||
},
|
||||
preDeny(value) {
|
||||
console.warn('No safety seal provided, proceeding without it.');
|
||||
Swal.showLoading();
|
||||
return OrderBookings.functions.complete(id, null, onAfterComplete).then(() => {
|
||||
Swal.close();
|
||||
});
|
||||
},
|
||||
preConfirm: (safetySeal) => {
|
||||
if (!safetySeal) {
|
||||
Swal.showValidationMessage('Safety seal number is required');
|
||||
}
|
||||
return safetySeal;
|
||||
},
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
return OrderBookings.functions.complete(id, result.value, onAfterComplete);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -230,6 +230,65 @@ const toPositiveInteger = (value) => {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const getRouteDepartmentId = () => {
|
||||
try {
|
||||
return toPositiveInteger(useRouter().currentRoute.value.params.departmentId);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedDepartmentId = (candidateDepartmentId = null) => {
|
||||
return toPositiveInteger(candidateDepartmentId ?? department_id.value) ?? getRouteDepartmentId();
|
||||
};
|
||||
|
||||
const getCurrentOrderDepartmentId = async (targetOrderId) => {
|
||||
const normalizedOrderId = toPositiveInteger(targetOrderId);
|
||||
if (!normalizedOrderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof SessionUser.objects.orders.functions.get_department_id === "function") {
|
||||
return toPositiveInteger(await SessionUser.objects.orders.functions.get_department_id(normalizedOrderId));
|
||||
}
|
||||
|
||||
if (typeof SessionUser.objects.orders.get.single === "function") {
|
||||
const order = await SessionUser.objects.orders.get.single(normalizedOrderId);
|
||||
return toPositiveInteger(order?.department_id);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ensureCurrentOrderDepartment = async (targetDepartmentId = null) => {
|
||||
const normalizedOrderId = toPositiveInteger(order_id.value);
|
||||
const normalizedDepartmentId = getSelectedDepartmentId(targetDepartmentId);
|
||||
|
||||
if (!normalizedOrderId || !normalizedDepartmentId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
let currentDepartmentId = null;
|
||||
try {
|
||||
currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
|
||||
} catch (error) {
|
||||
currentDepartmentId = null;
|
||||
}
|
||||
if (currentDepartmentId === normalizedDepartmentId) {
|
||||
department_id.value = normalizedDepartmentId;
|
||||
return true;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set.department_id(normalizedOrderId, normalizedDepartmentId);
|
||||
department_id.value = normalizedDepartmentId;
|
||||
return true;
|
||||
} catch (error) {
|
||||
parseError(error, "stepError");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const MATERIAL_PRODUCT_IDS = new Set([53, 54]);
|
||||
|
||||
const normalizeOrderMetadataString = (value) => {
|
||||
@@ -527,6 +586,7 @@ export const restoreStoredPosOrderId = async (
|
||||
customerId: null,
|
||||
departmentId: null,
|
||||
allowCompleted: false,
|
||||
syncDepartment: false,
|
||||
}
|
||||
) => {
|
||||
const storedOrderId = getStoredPosOrderId();
|
||||
@@ -551,15 +611,23 @@ export const restoreStoredPosOrderId = async (
|
||||
throw new Error("Stored order customer does not match current customer");
|
||||
}
|
||||
|
||||
const selectedDepartmentId = toPositiveInteger(options.departmentId);
|
||||
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
|
||||
throw new Error("Stored order department does not match current department");
|
||||
}
|
||||
|
||||
if (!options.allowCompleted && storedOrder?.completed_at) {
|
||||
throw new Error("Stored order is already completed");
|
||||
}
|
||||
|
||||
const selectedDepartmentId = toPositiveInteger(options.departmentId);
|
||||
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
|
||||
if (options.syncDepartment === true) {
|
||||
order_id.value = storedOrderId;
|
||||
const didSyncDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
|
||||
if (!didSyncDepartment) {
|
||||
throw new Error("Stored order department could not be changed to current department");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Stored order department does not match current department");
|
||||
}
|
||||
}
|
||||
|
||||
order_id.value = storedOrderId;
|
||||
return storedOrderId;
|
||||
} catch (error) {
|
||||
@@ -693,9 +761,9 @@ export const getOrderReg1 = async (orderId) => {
|
||||
};
|
||||
|
||||
/** Define the current order functions */
|
||||
export const createOrder = (options = { isMobile: false }) => {
|
||||
export const createOrder = async (options = { isMobile: false }) => {
|
||||
if (order_id.value) {
|
||||
return Promise.resolve(true);
|
||||
return ensureCurrentOrderDepartment(options.departmentId ?? getDepartment());
|
||||
}
|
||||
if (isCreatingOrder.value && createOrderRequest) {
|
||||
return createOrderRequest;
|
||||
@@ -705,13 +773,14 @@ export const createOrder = (options = { isMobile: false }) => {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const normalizedBookingId = toPositiveInteger(options.bookingId ?? selectedOrderBookingId.value);
|
||||
const selectedDepartmentId = getSelectedDepartmentId(options.departmentId);
|
||||
isCreatingOrder.value = true;
|
||||
createOrderRequest = axios
|
||||
.post(
|
||||
API_URL + "/orders",
|
||||
{
|
||||
customer_id: customer_id.value,
|
||||
department_id: getDepartment(),
|
||||
department_id: selectedDepartmentId ?? getDepartment(),
|
||||
reference: reference.value,
|
||||
notes: order_notes.value,
|
||||
po: order_po.value,
|
||||
@@ -735,6 +804,9 @@ export const createOrder = (options = { isMobile: false }) => {
|
||||
return false;
|
||||
}
|
||||
order_id.value = parseInt(response.data.data.id);
|
||||
if (selectedDepartmentId) {
|
||||
department_id.value = selectedDepartmentId;
|
||||
}
|
||||
// Set the order id in the local storage (To be make F5 safe)
|
||||
localStorage.setItem("pos_order_id", order_id.value);
|
||||
// Set the query parameters
|
||||
@@ -818,6 +890,7 @@ export const setDepartment = (id = null) => {
|
||||
|
||||
if (previousDepartmentKey !== String(nextDepartmentId ?? "")) {
|
||||
syncPendingBookingsDepartmentState();
|
||||
void ensureCurrentOrderDepartment(nextDepartmentId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1233,20 +1306,34 @@ export const isAddonRestricted = (addon) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export function loadOrderDetails(onAfterSuccess = null) {
|
||||
getOrderDetails().then((response) => {
|
||||
export function loadOrderDetails(onAfterSuccess = null, options = {}) {
|
||||
return getOrderDetails().then(async (response) => {
|
||||
console.log(response);
|
||||
const selectedDepartmentId = getSelectedDepartmentId(options.departmentId ?? null);
|
||||
if (
|
||||
response &&
|
||||
options.syncDepartmentWithSelection === true &&
|
||||
selectedDepartmentId &&
|
||||
toPositiveInteger(response.department_id) !== selectedDepartmentId
|
||||
) {
|
||||
await ensureCurrentOrderDepartment(selectedDepartmentId);
|
||||
}
|
||||
if (response && onAfterSuccess) {
|
||||
onAfterSuccess(response);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
/** Set the order id */
|
||||
export const setOrderId = (id) => {
|
||||
export const setOrderId = (id, options = {}) => {
|
||||
const selectedDepartmentId = getSelectedDepartmentId(options.departmentId ?? null);
|
||||
order_id.value = id;
|
||||
// Load the order details
|
||||
loadOrderDetails();
|
||||
loadOrderDetails(null, {
|
||||
departmentId: selectedDepartmentId,
|
||||
syncDepartmentWithSelection: options.syncDepartmentWithSelection !== false,
|
||||
});
|
||||
// Load the order items
|
||||
loadOrderItems();
|
||||
};
|
||||
|
||||
@@ -86,7 +86,13 @@ const deleteBusy = computed(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.gateway?.id, props.gateway?.label, props.gateway?.is_primary, props.metadataForm],
|
||||
[
|
||||
() => props.gateway?.id,
|
||||
() => props.gateway?.label,
|
||||
() => props.gateway?.is_primary,
|
||||
() => props.metadataForm?.label,
|
||||
() => props.metadataForm?.is_primary,
|
||||
],
|
||||
() => {
|
||||
metadataLabel.value = String(props.metadataForm?.label ?? props.gateway?.label ?? "");
|
||||
metadataPrimary.value = Boolean(props.metadataForm?.is_primary ?? props.gateway?.is_primary);
|
||||
@@ -95,7 +101,11 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.gateway?.id, props.gateway?.department_transport_mode, props.transportMode],
|
||||
[
|
||||
() => props.gateway?.id,
|
||||
() => props.gateway?.department_transport_mode,
|
||||
() => props.transportMode,
|
||||
],
|
||||
() => {
|
||||
localTransportMode.value = props.transportMode || props.gateway?.department_transport_mode || "gateway";
|
||||
},
|
||||
@@ -103,7 +113,10 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.gateway?.id, props.rotateConfirmation],
|
||||
[
|
||||
() => props.gateway?.id,
|
||||
() => props.rotateConfirmation,
|
||||
],
|
||||
() => {
|
||||
localRotateConfirmation.value = props.rotateConfirmation || "";
|
||||
},
|
||||
@@ -111,7 +124,10 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.gateway?.id, props.deleteConfirmation],
|
||||
[
|
||||
() => props.gateway?.id,
|
||||
() => props.deleteConfirmation,
|
||||
],
|
||||
() => {
|
||||
localDeleteConfirmation.value = props.deleteConfirmation || "";
|
||||
},
|
||||
|
||||
@@ -76,7 +76,13 @@ const deleteBusy = computed(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.gateway?.id, props.gateway?.label, props.gateway?.is_primary, props.metadataForm],
|
||||
[
|
||||
() => props.gateway?.id,
|
||||
() => props.gateway?.label,
|
||||
() => props.gateway?.is_primary,
|
||||
() => props.metadataForm?.label,
|
||||
() => props.metadataForm?.is_primary,
|
||||
],
|
||||
() => {
|
||||
metadataLabel.value = String(props.metadataForm?.label ?? props.gateway?.label ?? "");
|
||||
metadataPrimary.value = Boolean(props.metadataForm?.is_primary ?? props.gateway?.is_primary);
|
||||
@@ -85,7 +91,11 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.gateway?.id, props.gateway?.department_transport_mode, props.transportMode],
|
||||
[
|
||||
() => props.gateway?.id,
|
||||
() => props.gateway?.department_transport_mode,
|
||||
() => props.transportMode,
|
||||
],
|
||||
() => {
|
||||
localTransportMode.value = props.transportMode || props.gateway?.department_transport_mode || "gateway";
|
||||
},
|
||||
@@ -93,7 +103,10 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.gateway?.id, props.rotateConfirmation],
|
||||
[
|
||||
() => props.gateway?.id,
|
||||
() => props.rotateConfirmation,
|
||||
],
|
||||
() => {
|
||||
localRotateConfirmation.value = props.rotateConfirmation || "";
|
||||
},
|
||||
@@ -101,7 +114,10 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.gateway?.id, props.deleteConfirmation],
|
||||
[
|
||||
() => props.gateway?.id,
|
||||
() => props.deleteConfirmation,
|
||||
],
|
||||
() => {
|
||||
localDeleteConfirmation.value = props.deleteConfirmation || "";
|
||||
},
|
||||
|
||||
@@ -2187,6 +2187,26 @@
|
||||
"succeeded": "Succesfuld"
|
||||
}
|
||||
},
|
||||
"invoicing_period": {
|
||||
"monthly_split": {
|
||||
"button": "Opdel fakturaer efter måned",
|
||||
"confirm_text": "Opdel ikke-fakturerede fakturasamlinger i den valgte periode efter ordremåned?",
|
||||
"confirm_yes": "Ja, opdel efter måned",
|
||||
"error_title": "Månedsopdeling mislykkedes",
|
||||
"preview_apply": "Udfør opdeling",
|
||||
"preview_collection_title": "Fakturasamling #{id}",
|
||||
"preview_create_new": "Opret ny fakturasamling",
|
||||
"preview_existing": "Behold oprindelig fakturasamling #{id}",
|
||||
"preview_no_changes_text": "Den valgte periode har ingen fakturasamlinger, der vil blive ændret.",
|
||||
"preview_no_changes_title": "Ingen fakturaer at opdele",
|
||||
"preview_orders": "{count} ordrer",
|
||||
"preview_skipped_title": "Sprunget over",
|
||||
"preview_summary": "Behandlede {processed} fakturasamlinger. Vil opdele {changed} og springe {skipped} over.",
|
||||
"preview_title": "Forhåndsvis månedsopdeling",
|
||||
"success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.",
|
||||
"success_title": "Månedsopdeling fuldført"
|
||||
}
|
||||
},
|
||||
"login_qr": {
|
||||
"camera_error": "Kamera fejl",
|
||||
"login_with_password": "Log ind med adgangskode",
|
||||
|
||||
@@ -478,16 +478,50 @@
|
||||
"capture_payment_intent": "Payment Intent konnte nicht erfasst werden.",
|
||||
"delete_payment_intent": "Payment Intent konnte nicht gel\u00f6scht werden."
|
||||
},
|
||||
"details": {
|
||||
"payment_intent_id": "Payment Intent ID",
|
||||
"amount_received": "Erhaltener Betrag",
|
||||
"status": "Status",
|
||||
"payment_method": "Zahlungsmethode",
|
||||
"created_at": "Erstellt am",
|
||||
"metadata": "Metadaten",
|
||||
"none": "Keine"
|
||||
}
|
||||
"details": {
|
||||
"payment_intent_id": "Payment Intent ID",
|
||||
"amount_received": "Erhaltener Betrag",
|
||||
"status": "Status",
|
||||
"payment_method": "Zahlungsmethode",
|
||||
"created_at": "Erstellt am",
|
||||
"metadata": "Metadaten",
|
||||
"none": "Keine"
|
||||
},
|
||||
"email": {
|
||||
"action": "Kartenzahlung (E-Mail)",
|
||||
"eyebrow": "Online-Zahlung",
|
||||
"title": "Kartenzahlung (E-Mail)",
|
||||
"help": "Senden Sie dem Kunden eine Stripe-Zahlungsrechnung per E-Mail.",
|
||||
"address_label": "E-Mail",
|
||||
"address_placeholder": "kunde [at] beispiel.de",
|
||||
"submit": "Senden",
|
||||
"status_label": "Zahlungsstatus",
|
||||
"invoice_link_label": "Zahlungslink",
|
||||
"invoice_link_action": "Öffnen",
|
||||
"states": {
|
||||
"open": "Warten auf Zahlung",
|
||||
"paid": "Bezahlt",
|
||||
"void": "Storniert",
|
||||
"uncollectible": "Uneinbringlich",
|
||||
"deleted": "Gelöscht",
|
||||
"draft": "Entwurf",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"messages": {
|
||||
"sent": "Der Zahlungslink wurde gesendet. Der Status wird automatisch aktualisiert.",
|
||||
"paid": "Die Zahlung wurde empfangen. Die Bestellung kann jetzt abgeschlossen werden.",
|
||||
"closed": "Der Online-Zahlungslink wurde geschlossen.",
|
||||
"active": "Für diese Bestellung gibt es bereits einen aktiven Zahlungslink."
|
||||
},
|
||||
"errors": {
|
||||
"invalid_email": "Geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"load": "Der Status des Zahlungslinks konnte nicht geladen werden.",
|
||||
"send": "Der Zahlungslink konnte nicht gesendet werden.",
|
||||
"cancel": "Der Zahlungslink konnte nicht storniert werden.",
|
||||
"complete": "Die Bestellung konnte nicht abgeschlossen werden."
|
||||
}
|
||||
}
|
||||
},
|
||||
"price": "Preis",
|
||||
"price_changed_from": "Preis ge?ndert von {from} DKK auf {to} DKK / Einheit ({total} DKK gesamt)",
|
||||
"price_dkk": "Preis (DKK)",
|
||||
@@ -2153,6 +2187,26 @@
|
||||
"succeeded": "Erfolgreich"
|
||||
}
|
||||
},
|
||||
"invoicing_period": {
|
||||
"monthly_split": {
|
||||
"button": "Split invoices by month",
|
||||
"confirm_text": "Split non-invoiced collected invoices in the selected period into monthly invoice collections?",
|
||||
"confirm_yes": "Yes, split by month",
|
||||
"error_title": "Monthly split failed",
|
||||
"preview_apply": "Apply split",
|
||||
"preview_collection_title": "Collection #{id}",
|
||||
"preview_create_new": "Create new collection",
|
||||
"preview_existing": "Keep original collection #{id}",
|
||||
"preview_no_changes_text": "The selected period has no invoice collections that will be changed.",
|
||||
"preview_no_changes_title": "No invoices to split",
|
||||
"preview_orders": "{count} orders",
|
||||
"preview_skipped_title": "Skipped",
|
||||
"preview_summary": "Processed {processed} invoice collections. Will split {changed}, skipped {skipped}.",
|
||||
"preview_title": "Preview monthly split",
|
||||
"success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
|
||||
"success_title": "Monthly split completed"
|
||||
}
|
||||
},
|
||||
"login_qr": {
|
||||
"camera_error": "Kamerafehler",
|
||||
"login_with_password": "Mit Passwort anmelden",
|
||||
|
||||
@@ -2187,6 +2187,26 @@
|
||||
"succeeded": "Successful"
|
||||
}
|
||||
},
|
||||
"invoicing_period": {
|
||||
"monthly_split": {
|
||||
"button": "Split invoices by month",
|
||||
"confirm_text": "Split non-invoiced collected invoices in the selected period into monthly invoice collections?",
|
||||
"confirm_yes": "Yes, split by month",
|
||||
"error_title": "Monthly split failed",
|
||||
"preview_apply": "Apply split",
|
||||
"preview_collection_title": "Collection #{id}",
|
||||
"preview_create_new": "Create new collection",
|
||||
"preview_existing": "Keep original collection #{id}",
|
||||
"preview_no_changes_text": "The selected period has no invoice collections that will be changed.",
|
||||
"preview_no_changes_title": "No invoices to split",
|
||||
"preview_orders": "{count} orders",
|
||||
"preview_skipped_title": "Skipped",
|
||||
"preview_summary": "Processed {processed} invoice collections. Will split {changed}, skipped {skipped}.",
|
||||
"preview_title": "Preview monthly split",
|
||||
"success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
|
||||
"success_title": "Monthly split completed"
|
||||
}
|
||||
},
|
||||
"login_qr": {
|
||||
"camera_error": "Camera Error",
|
||||
"login_with_password": "Login with password",
|
||||
|
||||
@@ -486,6 +486,40 @@
|
||||
"created_at": "Opprettet",
|
||||
"metadata": "Metadata",
|
||||
"none": "Ingen"
|
||||
},
|
||||
"email": {
|
||||
"action": "Kortbetaling (e-post)",
|
||||
"eyebrow": "Nettbetaling",
|
||||
"title": "Kortbetaling (e-post)",
|
||||
"help": "Send en Stripe-betalingsfaktura til kunden via e-post.",
|
||||
"address_label": "E-post",
|
||||
"address_placeholder": "kunde [at] eksempel.no",
|
||||
"submit": "Send",
|
||||
"status_label": "Betalingsstatus",
|
||||
"invoice_link_label": "Betalingslenke",
|
||||
"invoice_link_action": "\u00c5pne",
|
||||
"states": {
|
||||
"open": "Venter p\u00e5 betaling",
|
||||
"paid": "Betalt",
|
||||
"void": "Annullert",
|
||||
"uncollectible": "Kan ikke innkreves",
|
||||
"deleted": "Slettet",
|
||||
"draft": "Utkast",
|
||||
"unknown": "Ukjent"
|
||||
},
|
||||
"messages": {
|
||||
"sent": "Betalingslenken er sendt. Status oppdateres automatisk.",
|
||||
"paid": "Betalingen er mottatt. Ordren kan n\u00e5 fullf\u00f8res.",
|
||||
"closed": "Den elektroniske betalingslenken er lukket.",
|
||||
"active": "Det finnes allerede en aktiv betalingslenke for denne ordren."
|
||||
},
|
||||
"errors": {
|
||||
"invalid_email": "Skriv inn en gyldig e-postadresse.",
|
||||
"load": "Kunne ikke laste status for betalingslenken.",
|
||||
"send": "Kunne ikke sende betalingslenken.",
|
||||
"cancel": "Kunne ikke annullere betalingslenken.",
|
||||
"complete": "Kunne ikke fullf\u00f8re ordren."
|
||||
}
|
||||
}
|
||||
},
|
||||
"price": "Pris",
|
||||
@@ -2153,6 +2187,26 @@
|
||||
"succeeded": "Vellykket"
|
||||
}
|
||||
},
|
||||
"invoicing_period": {
|
||||
"monthly_split": {
|
||||
"button": "Split invoices by month",
|
||||
"confirm_text": "Split non-invoiced collected invoices in the selected period into monthly invoice collections?",
|
||||
"confirm_yes": "Yes, split by month",
|
||||
"error_title": "Monthly split failed",
|
||||
"preview_apply": "Apply split",
|
||||
"preview_collection_title": "Collection #{id}",
|
||||
"preview_create_new": "Create new collection",
|
||||
"preview_existing": "Keep original collection #{id}",
|
||||
"preview_no_changes_text": "The selected period has no invoice collections that will be changed.",
|
||||
"preview_no_changes_title": "No invoices to split",
|
||||
"preview_orders": "{count} orders",
|
||||
"preview_skipped_title": "Skipped",
|
||||
"preview_summary": "Processed {processed} invoice collections. Will split {changed}, skipped {skipped}.",
|
||||
"preview_title": "Preview monthly split",
|
||||
"success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
|
||||
"success_title": "Monthly split completed"
|
||||
}
|
||||
},
|
||||
"login_qr": {
|
||||
"camera_error": "Kamerafeil",
|
||||
"login_with_password": "Logg inn med passord",
|
||||
|
||||
@@ -478,16 +478,50 @@
|
||||
"capture_payment_intent": "Kunde inte slutf\u00f6ra Payment Intent.",
|
||||
"delete_payment_intent": "Kunde inte radera Payment Intent."
|
||||
},
|
||||
"details": {
|
||||
"payment_intent_id": "Payment Intent ID",
|
||||
"amount_received": "Mottaget belopp",
|
||||
"status": "Status",
|
||||
"payment_method": "Betalningsmetod",
|
||||
"created_at": "Skapad",
|
||||
"metadata": "Metadata",
|
||||
"none": "Ingen"
|
||||
}
|
||||
"details": {
|
||||
"payment_intent_id": "Payment Intent ID",
|
||||
"amount_received": "Mottaget belopp",
|
||||
"status": "Status",
|
||||
"payment_method": "Betalningsmetod",
|
||||
"created_at": "Skapad",
|
||||
"metadata": "Metadata",
|
||||
"none": "Ingen"
|
||||
},
|
||||
"email": {
|
||||
"action": "Kortbetalning (e-post)",
|
||||
"eyebrow": "Onlinebetalning",
|
||||
"title": "Kortbetalning (e-post)",
|
||||
"help": "Skicka en Stripe-betalningsfaktura till kunden via e-post.",
|
||||
"address_label": "E-post",
|
||||
"address_placeholder": "kund [at] exempel.se",
|
||||
"submit": "Skicka",
|
||||
"status_label": "Betalningsstatus",
|
||||
"invoice_link_label": "Betalningslänk",
|
||||
"invoice_link_action": "Öppna",
|
||||
"states": {
|
||||
"open": "Väntar på betalning",
|
||||
"paid": "Betald",
|
||||
"void": "Annullerad",
|
||||
"uncollectible": "Ej indrivningsbar",
|
||||
"deleted": "Raderad",
|
||||
"draft": "Utkast",
|
||||
"unknown": "Okänd"
|
||||
},
|
||||
"messages": {
|
||||
"sent": "Betalningslänken har skickats. Status uppdateras automatiskt.",
|
||||
"paid": "Betalningen har tagits emot. Ordern kan nu slutföras.",
|
||||
"closed": "Onlinebetalningslänken är stängd.",
|
||||
"active": "Det finns redan en aktiv betalningslänk för denna order."
|
||||
},
|
||||
"errors": {
|
||||
"invalid_email": "Ange en giltig e-postadress.",
|
||||
"load": "Kunde inte ladda betalningslänkens status.",
|
||||
"send": "Kunde inte skicka betalningslänken.",
|
||||
"cancel": "Kunde inte annullera betalningslänken.",
|
||||
"complete": "Kunde inte slutföra ordern."
|
||||
}
|
||||
}
|
||||
},
|
||||
"price": "Price",
|
||||
"price_changed_from": "Price changed from {from} DKK to {to} DKK / Unit ({total} DKK total)",
|
||||
"price_dkk": "Price (DKK)",
|
||||
@@ -2153,6 +2187,26 @@
|
||||
"succeeded": "Lyckad"
|
||||
}
|
||||
},
|
||||
"invoicing_period": {
|
||||
"monthly_split": {
|
||||
"button": "Split invoices by month",
|
||||
"confirm_text": "Split non-invoiced collected invoices in the selected period into monthly invoice collections?",
|
||||
"confirm_yes": "Yes, split by month",
|
||||
"error_title": "Monthly split failed",
|
||||
"preview_apply": "Apply split",
|
||||
"preview_collection_title": "Collection #{id}",
|
||||
"preview_create_new": "Create new collection",
|
||||
"preview_existing": "Keep original collection #{id}",
|
||||
"preview_no_changes_text": "The selected period has no invoice collections that will be changed.",
|
||||
"preview_no_changes_title": "No invoices to split",
|
||||
"preview_orders": "{count} orders",
|
||||
"preview_skipped_title": "Skipped",
|
||||
"preview_summary": "Processed {processed} invoice collections. Will split {changed}, skipped {skipped}.",
|
||||
"preview_title": "Preview monthly split",
|
||||
"success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
|
||||
"success_title": "Monthly split completed"
|
||||
}
|
||||
},
|
||||
"login_qr": {
|
||||
"camera_error": "Kamerafel",
|
||||
"login_with_password": "Login with password",
|
||||
|
||||
@@ -125,7 +125,10 @@ const loadOrder = async () => {
|
||||
economicModule.value = response.data.includes.economicModuleOrders; // Get the economic module orders
|
||||
stripeModule.value = response.data.includes.stripeModuleOrders; // Get the stripe module orders
|
||||
closed_at.value = response.data.data.closed_at;
|
||||
setOrderId(orderId.value);
|
||||
setOrderId(orderId.value, {
|
||||
departmentId: response.data.data.department_id,
|
||||
syncDepartmentWithSelection: false,
|
||||
});
|
||||
await fetchAttachments(orderId.value);
|
||||
isLoading.value = false;
|
||||
selectCustomer(customer.value.economic_customer);
|
||||
@@ -616,12 +619,12 @@ const refreshOrderMetadata = async () => {
|
||||
await loadOrder();
|
||||
};
|
||||
|
||||
const showChangeCustomerFieldForm = async (id) => {
|
||||
return SessionUser.objects.orders.functions.showChangeCustomerForm(id, refreshOrderMetadata);
|
||||
const showChangeCustomerFieldForm = async (id = null) => {
|
||||
return SessionUser.objects.orders.functions.showChangeCustomerForm(id ?? orderId.value, refreshOrderMetadata);
|
||||
};
|
||||
|
||||
const showChangeInvoiceCollectionFieldForm = async (id) => {
|
||||
return SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(id, refreshOrderMetadata);
|
||||
const showChangeInvoiceCollectionFieldForm = async (id = null) => {
|
||||
return SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(id ?? orderId.value, refreshOrderMetadata);
|
||||
};
|
||||
|
||||
watch(() => invoiceCollectionId.value, async () => {
|
||||
@@ -751,11 +754,15 @@ const getReceiptTaxPercentage = () => {
|
||||
return Number.isFinite(taxPercentage) ? taxPercentage : 0;
|
||||
};
|
||||
|
||||
const openReceiptWindow = () => {
|
||||
const openReceiptWindow = async () => {
|
||||
if (!orderId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading.value || !order.value?.id) {
|
||||
await loadOrder();
|
||||
}
|
||||
|
||||
openOrderReceiptPrintWindow({
|
||||
orderId: orderId.value,
|
||||
invoiceId: getInvoiceCollectionValue()?.id || order.value?.invoice_collection_id || economicModule.value?.invoice_id || null,
|
||||
@@ -1502,7 +1509,7 @@ const isDisplayingReceipt = () => {
|
||||
|
||||
</notFoundFallBackPageWrapper>
|
||||
</DepartmentDashboardPageWrapper>
|
||||
<div v-show="isDisplayingReceipt()">
|
||||
<div v-show="isDisplayingReceipt()" data-disable-auto-excel-export="1">
|
||||
<!-- Image -->
|
||||
<div class="has-text-centered py-6 keep-bg-during-print" :style="{'background-color': Colors.menus.parentBackgroundColor}">
|
||||
<img src="@/assets/branding/truckwash-banner-white-compressed.png" alt="Truck Wash Logo" style="max-width: 100%; max-height: 200px;"/>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import FormDisplay from "@/components/displays/FormDisplay.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -11,11 +9,6 @@ const props = defineProps({
|
||||
booking: Object,
|
||||
})
|
||||
|
||||
|
||||
const onSuccessfulCertificateSubmission = () => {
|
||||
props.booking.load()
|
||||
}
|
||||
|
||||
const services = {
|
||||
1: t('department_dashboard.bookings.services.exterior_semi_trailer'),
|
||||
2: t('department_dashboard.bookings.services.exterior_trailer_wash'),
|
||||
@@ -73,19 +66,11 @@ const download = (booking_id) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send certificate -->
|
||||
<div class="column is-8">
|
||||
<!-- Certificate status -->
|
||||
<div class="column is-8" v-if="props.booking.isWashCertificateCompleted() || props.booking.isWashCompleted()">
|
||||
<div class="box">
|
||||
<!-- Generate certificate -->
|
||||
<template v-if="props.booking.isWashCertificatePending() && props.booking.isLoaded()">
|
||||
<FormDisplay
|
||||
:form_identifier="'GENERATE_BOOKING_WASH_CERTIFICATE'"
|
||||
v-bind:validator_options="{validateBookingId: {default: props.booking.id, locked: true, visible: false}}"
|
||||
:onSuccessfulSubmission="onSuccessfulCertificateSubmission"
|
||||
/>
|
||||
</template>
|
||||
<!-- Certificate -->
|
||||
<template v-else-if="props.booking.isWashCertificateCompleted()">
|
||||
<template v-if="props.booking.isWashCertificateCompleted()">
|
||||
<div class="message is-success">
|
||||
<div class="message-header">
|
||||
<p>{{ $t('department_dashboard.bookings.wash_certificate') }}</p>
|
||||
@@ -107,13 +92,6 @@ const download = (booking_id) => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="!props.booking.isWashCertificatePending() && !props.booking.isWashCompleted() && !props.booking.isWashCancelled() && props.booking.isLoaded()">
|
||||
<FormDisplay
|
||||
:form_identifier="'COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE'"
|
||||
v-bind:validator_options="{validateBookingId: {default: props.booking.id, locked: true, visible: false}}"
|
||||
:onSuccessfulSubmission="onSuccessfulCertificateSubmission"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="props.booking.isWashCompleted()">
|
||||
<div class="message is-success">
|
||||
<div class="message-header">
|
||||
@@ -157,4 +135,4 @@ const download = (booking_id) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -115,13 +115,28 @@ const toggleAllDepartments = () => {
|
||||
: [...departmentOptions.value.map((department) => department.id)];
|
||||
onDepartmentsChange();
|
||||
};
|
||||
|
||||
const formatDateSelectionValue = (date) => {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const onDateSelectionChange = (startDate, endDate) => {
|
||||
selectDate(formatDateSelectionValue(startDate), formatDateSelectionValue(endDate));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="columns is-vcentered is-multiline" data-testid="daily-report-navigation">
|
||||
<div class="column is-12" data-testid="daily-report-date-controls">
|
||||
<DatePeriodSelector
|
||||
:on-selection-change="(startDate, endDate) => selectDate(startDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0])"
|
||||
:on-selection-change="onDateSelectionChange"
|
||||
: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"
|
||||
|
||||
@@ -368,6 +368,7 @@ const isSaving = ref(false);
|
||||
const isUploadingTaskAttachment = ref(false);
|
||||
const deletingTaskAttachmentId = ref(null);
|
||||
const isStudioFullscreen = ref(false);
|
||||
const hasNativeStudioFullscreen = ref(false);
|
||||
const layoutSaveTimer = ref(null);
|
||||
const suppressLayoutSave = ref(false);
|
||||
const exportText = ref("");
|
||||
@@ -1797,7 +1798,12 @@ const refitStudioCanvas = () => {
|
||||
};
|
||||
|
||||
const syncStudioFullscreenState = () => {
|
||||
if (document.fullscreenElement !== studioRoot.value) {
|
||||
const activeFullscreenElement = document.fullscreenElement;
|
||||
if (activeFullscreenElement === studioRoot.value) {
|
||||
hasNativeStudioFullscreen.value = true;
|
||||
isStudioFullscreen.value = true;
|
||||
} else if (hasNativeStudioFullscreen.value || activeFullscreenElement) {
|
||||
hasNativeStudioFullscreen.value = false;
|
||||
isStudioFullscreen.value = false;
|
||||
}
|
||||
refitStudioCanvas();
|
||||
@@ -1815,15 +1821,18 @@ const toggleStudioFullscreen = async () => {
|
||||
if (document.fullscreenElement === studioRoot.value && document.exitFullscreen) {
|
||||
await document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
hasNativeStudioFullscreen.value = false;
|
||||
isStudioFullscreen.value = false;
|
||||
refitStudioCanvas();
|
||||
return;
|
||||
}
|
||||
|
||||
hasNativeStudioFullscreen.value = false;
|
||||
isStudioFullscreen.value = true;
|
||||
await nextTick();
|
||||
if (studioRoot.value?.requestFullscreen && !document.fullscreenElement) {
|
||||
await studioRoot.value.requestFullscreen().catch(() => {});
|
||||
hasNativeStudioFullscreen.value = document.fullscreenElement === studioRoot.value;
|
||||
}
|
||||
refitStudioCanvas();
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import { dates } from '../imports/InvoicingBillingPeriodImportDates.vue';
|
||||
import { view } from '../imports/InvoicingBillingPeriodImportView.vue';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import { departments, getDepartments } from '@/components/pagination/departmentTabs.vue';
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from "vue-i18n";
|
||||
const { t } = useI18n();
|
||||
const onSelectionChange = (startDate: Date, endDate: Date) => {
|
||||
console.log("Selection changed:", startDate, endDate);
|
||||
dates.functions.setSelection(startDate, endDate);
|
||||
@@ -12,6 +15,7 @@ import {ref, watch} from "vue";
|
||||
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
|
||||
|
||||
const isReloadAnimationActive = ref(false);
|
||||
const isMonthlySplitInProgress = ref(false);
|
||||
const showReloadAnimation = () => {
|
||||
isReloadAnimationActive.value = true;
|
||||
};
|
||||
@@ -35,6 +39,127 @@ watch(() => departments.value, (newDepartments) => {
|
||||
name: department.name,
|
||||
}));
|
||||
}, { immediate: true });
|
||||
|
||||
const escapeHtml = (value: unknown) => String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const getSplitResponsePayload = (response: any = {}) => response?.data?.data ?? response?.data ?? {};
|
||||
|
||||
const buildMonthlySplitPreviewHtml = (preview: any = {}) => {
|
||||
const changed = Array.isArray(preview.changed) ? preview.changed : [];
|
||||
const skipped = Array.isArray(preview.skipped) ? preview.skipped : [];
|
||||
const changedHtml = changed.map((item: any) => {
|
||||
const months = Array.isArray(item.months) ? item.months : [];
|
||||
const monthRows = months.map((month: any) => {
|
||||
const target = month?.will_create_collection
|
||||
? t('invoicing_period.monthly_split.preview_create_new')
|
||||
: t('invoicing_period.monthly_split.preview_existing', {
|
||||
id: month?.target_invoice_collection_id ?? month?.invoice_collection_id ?? item.invoice_collection_id,
|
||||
});
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(month?.month)}</td>
|
||||
<td>${escapeHtml(t('invoicing_period.monthly_split.preview_orders', { count: month?.order_count ?? 0 }))}</td>
|
||||
<td>${escapeHtml(target)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<section class="monthly-split-preview__collection">
|
||||
<strong>${escapeHtml(t('invoicing_period.monthly_split.preview_collection_title', { id: item.invoice_collection_id }))}</strong>
|
||||
<table class="table is-fullwidth is-narrow monthly-split-preview__table">
|
||||
<tbody>${monthRows}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const skippedHtml = skipped.length > 0
|
||||
? `
|
||||
<section class="monthly-split-preview__skipped">
|
||||
<strong>${escapeHtml(t('invoicing_period.monthly_split.preview_skipped_title'))}</strong>
|
||||
<ul>
|
||||
${skipped.slice(0, 8).map((item: any) => `
|
||||
<li>#${escapeHtml(item.invoice_collection_id)}: ${escapeHtml(item.message || item.reason || '')}</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</section>
|
||||
`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="monthly-split-preview" style="text-align: left;">
|
||||
<p>${escapeHtml(t('invoicing_period.monthly_split.preview_summary', {
|
||||
processed: preview.processed_count ?? 0,
|
||||
changed: preview.changed_count ?? 0,
|
||||
skipped: preview.skipped_count ?? 0,
|
||||
}))}</p>
|
||||
${changedHtml}
|
||||
${skippedHtml}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const onSplitCollectedInvoicesByMonth = async () => {
|
||||
isMonthlySplitInProgress.value = true;
|
||||
try {
|
||||
const dateFrom = dates.computed.formattedStartDate.value;
|
||||
const dateTo = dates.computed.formattedEndDate.value;
|
||||
const previewResponse = await SessionUser.objects.collectedOrderInvoices.functions.split_by_month(dateFrom, dateTo, { preview: true });
|
||||
const previewResult = getSplitResponsePayload(previewResponse);
|
||||
|
||||
if ((previewResult.changed_count ?? 0) < 1) {
|
||||
await Swal.fire({
|
||||
icon: 'info',
|
||||
title: t('invoicing_period.monthly_split.preview_no_changes_title'),
|
||||
html: buildMonthlySplitPreviewHtml(previewResult) || escapeHtml(t('invoicing_period.monthly_split.preview_no_changes_text')),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmation = await Swal.fire({
|
||||
title: t('invoicing_period.monthly_split.preview_title'),
|
||||
html: buildMonthlySplitPreviewHtml(previewResult),
|
||||
icon: 'info',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t('invoicing_period.monthly_split.preview_apply'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
width: '56rem',
|
||||
});
|
||||
|
||||
if (!confirmation.isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await SessionUser.objects.collectedOrderInvoices.functions.split_by_month(dateFrom, dateTo, { preview: false });
|
||||
const result = getSplitResponsePayload(response);
|
||||
await Swal.fire({
|
||||
icon: 'success',
|
||||
title: t('invoicing_period.monthly_split.success_title'),
|
||||
text: t('invoicing_period.monthly_split.success_text', {
|
||||
processed: result.processed_count ?? 0,
|
||||
changed: result.changed_count ?? 0,
|
||||
skipped: result.skipped_count ?? 0,
|
||||
}),
|
||||
});
|
||||
onSelectionChange(dates.variables.start.value, dates.variables.end.value);
|
||||
showReloadAnimation();
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.monthly_split.error_title'),
|
||||
text: SessionUser.functions.parseErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
isMonthlySplitInProgress.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -80,6 +205,21 @@ watch(() => departments.value, (newDepartments) => {
|
||||
</template>
|
||||
<template #right>
|
||||
|
||||
<div class="level-item">
|
||||
<button
|
||||
class="button is-warning is-outlined"
|
||||
:class="{'is-loading': isMonthlySplitInProgress}"
|
||||
:disabled="isMonthlySplitInProgress"
|
||||
@click="onSplitCollectedInvoicesByMonth"
|
||||
data-testid="invoicing-period-split-by-month-button"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</span>
|
||||
<span>{{ t('invoicing_period.monthly_split.button') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reload button with animation -->
|
||||
<div class="level-item">
|
||||
<button
|
||||
|
||||
@@ -38,8 +38,8 @@ const fetchFixedPricingDistribution = async () => {
|
||||
'/superuser/invoicing/period/distribution/fixed-pricing',
|
||||
'GET',
|
||||
{
|
||||
dateFrom: dates.variables.start.value.toISOString().split('T')[0],
|
||||
dateTo: dates.variables.end.value.toISOString().split('T')[0],
|
||||
dateFrom: dates.computed.formattedStartDate.value,
|
||||
dateTo: dates.computed.formattedEndDate.value,
|
||||
}
|
||||
).then((response: any) => {
|
||||
|
||||
@@ -74,8 +74,8 @@ const fetchBookedDepartment75Distribution = async () => {
|
||||
'/superuser/invoicing/period/distribution/v2/booked-department-75',
|
||||
'GET',
|
||||
{
|
||||
dateFrom: dates.variables.start.value.toISOString().split('T')[0],
|
||||
dateTo: dates.variables.end.value.toISOString().split('T')[0],
|
||||
dateFrom: dates.computed.formattedStartDate.value,
|
||||
dateTo: dates.computed.formattedEndDate.value,
|
||||
}
|
||||
).then((response: any) => {
|
||||
bookedDepartment75Distribution.value = getResponsePayload(response);
|
||||
@@ -140,8 +140,8 @@ const fetchVehicleSubscriptionDistribution = async () => {
|
||||
'/superuser/invoicing/period/distribution/wash-subscriptions',
|
||||
'GET',
|
||||
{
|
||||
dateFrom: dates.variables.start.value.toISOString().split('T')[0],
|
||||
dateTo: dates.variables.end.value.toISOString().split('T')[0],
|
||||
dateFrom: dates.computed.formattedStartDate.value,
|
||||
dateTo: dates.computed.formattedEndDate.value,
|
||||
}
|
||||
).then((response: any) => {
|
||||
/**
|
||||
@@ -419,8 +419,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<StatisticsIncomeCard
|
||||
v-bind:force-display="{
|
||||
value: view.computed.currentViewTotalNetAmount.value,
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:icon="'fas fa-money-bill-wave'"
|
||||
@@ -434,8 +434,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<StatisticsIncomeCard
|
||||
v-bind:force-display="{
|
||||
value: view.computed.currentViewTotalNetAmountBooked.value,
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:icon="'fas fa-money-bill-wave'"
|
||||
@@ -451,8 +451,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<StatisticsIncomeCard
|
||||
v-bind:force-display="{
|
||||
value: view.computed.currentViewTotalNetAmountNotBooked.value,
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:icon="'fas fa-money-bill-wave'"
|
||||
@@ -478,8 +478,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<WhiteBoxCard :has-selection-style="true" :has-hover-effect="true" :has-border="true">
|
||||
<StatisticsIncomeCard v-bind:force-display="{
|
||||
value: department75CombinedTotals.calculatedDepartment75Amount || 0,
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:loading="!hasDepartment75CombinedBasis"
|
||||
@@ -495,8 +495,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<WhiteBoxCard :has-selection-style="true" :has-hover-effect="true" :has-border="true">
|
||||
<StatisticsIncomeCard v-bind:force-display="{
|
||||
value: vehicleSubscriptionDistribution?.total_subscription_price || 0,
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:loading="vehicleSubscriptionDistribution?.total_subscription_price === undefined"
|
||||
@@ -512,8 +512,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<WhiteBoxCard :has-selection-style="true" :has-hover-effect="true" :has-border="true">
|
||||
<StatisticsIncomeCard v-bind:force-display="{
|
||||
value: fixed_pricing_department_distribution?.total_fixed_price || 0,
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:loading="fixed_pricing_department_distribution?.total_fixed_price === undefined"
|
||||
@@ -610,8 +610,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<StatisticsIncomeCard
|
||||
v-bind:force-display="{
|
||||
value: fixed_pricing_department_distribution?.total_fixed_price || 0,
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:loading="fixed_pricing_department_distribution?.total_fixed_price === undefined"
|
||||
@@ -626,8 +626,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<WhiteBoxCard :has-selection-style="true" :has-hover-effect="true" :has-border="true">
|
||||
<StatisticsIncomeCard v-bind:force-display="{
|
||||
value: fixed_pricing_department_distribution?.total_original_price || 0,
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:loading="fixed_pricing_department_distribution?.total_original_price === undefined"
|
||||
@@ -645,8 +645,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<StatisticsIncomeCard
|
||||
v-bind:force-display="{
|
||||
value: (fixed_pricing_department_distribution?.total_fixed_price || 0) - (fixed_pricing_department_distribution?.total_original_price || 0),
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:loading="fixed_pricing_department_distribution?.total_fixed_price === undefined || fixed_pricing_department_distribution?.total_original_price === undefined"
|
||||
@@ -725,8 +725,8 @@ const formatCurrencyAmountOrDash = (amount: any) => {
|
||||
<WhiteBoxCard :has-selection-style="true" :has-hover-effect="true" :has-border="true">
|
||||
<StatisticsIncomeCard v-bind:force-display="{
|
||||
value: vehicleSubscriptionDistribution?.total_subscription_price || 0,
|
||||
start_date: dates.variables.start.value.toISOString().split('T')[0],
|
||||
end_date: dates.variables.end.value.toISOString().split('T')[0],
|
||||
start_date: dates.computed.formattedStartDate.value,
|
||||
end_date: dates.computed.formattedEndDate.value,
|
||||
error: null,
|
||||
}"
|
||||
v-bind:loading="vehicleSubscriptionDistribution?.total_subscription_price === undefined"
|
||||
|
||||
@@ -108,8 +108,8 @@ const getPeriod = async () => {
|
||||
'/superuser/invoicing/period',
|
||||
'GET',
|
||||
{
|
||||
dateFrom: dates.variables.start.value.toISOString().split('T')[0], // Start of the day
|
||||
dateTo: dates.variables.end.value.toISOString().split('T')[0], // End of the day
|
||||
dateFrom: dates.computed.formattedStartDate.value,
|
||||
dateTo: dates.computed.formattedEndDate.value,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ const variablesDates = {
|
||||
|
||||
// Function to format date to YYYY-MM-DD
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toISOString().split("T")[0];
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
const formatTime = (date: Date): string => {
|
||||
return date.toTimeString().split(" ")[0]; // HH:MM:SS
|
||||
@@ -70,9 +73,11 @@ const computedDates = {
|
||||
formattedEndTime,
|
||||
formattedDateRange,
|
||||
isEntireMonth: computed(() => {
|
||||
const isFirstDayOfMonth = new Date(startDate.value.getFullYear(), startDate.value.getMonth() - 1, 2).getDate() === startDate.value.getDate();
|
||||
const isSameMonth = startDate.value.getFullYear() === endDate.value.getFullYear()
|
||||
&& startDate.value.getMonth() === endDate.value.getMonth();
|
||||
const isFirstDayOfMonth = startDate.value.getDate() === 1;
|
||||
const isLastDayOfMonth = endDate.value.getDate() === new Date(endDate.value.getFullYear(), endDate.value.getMonth() + 1, 0).getDate();
|
||||
return isFirstDayOfMonth && isLastDayOfMonth;
|
||||
return isSameMonth && isFirstDayOfMonth && isLastDayOfMonth;
|
||||
}),
|
||||
isMultiMonthSelection: computed(() => {
|
||||
return startDate.value.getMonth() !== endDate.value.getMonth() || startDate.value.getFullYear() !== endDate.value.getFullYear();
|
||||
|
||||
@@ -397,8 +397,8 @@ const getTransactionQueryParameters = () => {
|
||||
:limit-results="false"
|
||||
:hide-pagination="false"
|
||||
:dates="{
|
||||
dateFrom: dates.variables.start.value.toISOString().split('T')[0],
|
||||
dateTo: dates.variables.end.value.toISOString().split('T')[0],
|
||||
dateFrom: dates.computed.formattedStartDate.value,
|
||||
dateTo: dates.computed.formattedEndDate.value,
|
||||
}"
|
||||
:show-only-with-ids="getTransactionIds(customer)"
|
||||
:query-parameters="getTransactionQueryParameters()"
|
||||
@@ -418,8 +418,8 @@ const getTransactionQueryParameters = () => {
|
||||
:auto-expand-all="view.variables.currentView.value === 'invoice_per_order'"
|
||||
:query-parameters="getTransactionQueryParameters()"
|
||||
:dates="{
|
||||
dateFrom: dates.variables.start.value.toISOString().split('T')[0],
|
||||
dateTo: dates.variables.end.value.toISOString().split('T')[0],
|
||||
dateFrom: dates.computed.formattedStartDate.value,
|
||||
dateTo: dates.computed.formattedEndDate.value,
|
||||
}"
|
||||
:show-only-with-ids="getTransactionIds(customer)"
|
||||
:excluded-order-ids="getExcludedTransactionIds(customer)"
|
||||
|
||||
@@ -377,11 +377,6 @@ const getColspan = () => {
|
||||
<!-- Actions -->
|
||||
<td>
|
||||
<div class="buttons is-right">
|
||||
<button v-if="canEditBooking(booking) && !booking.order_id && SessionUser.canAccessAdmin()" class="button is-small is-warning" @click="SessionUser.objects.order_bookings.functions.showCompleteConfirmationModal(booking.id, loadList)">
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit"></i>
|
||||
</span>
|
||||
</button>
|
||||
<button v-if="!!booking.order_id" class="button is-small is-success" disabled>
|
||||
<span class="icon">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
@@ -473,11 +468,6 @@ const getColspan = () => {
|
||||
<i class="fas fa-trash-alt fa-lg"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="button is-warning is-small" v-if="canEditBooking(booking) && !booking.order_id && SessionUser.canAccessAdmin()" @click="SessionUser.objects.order_bookings.functions.showCompleteConfirmationModal(booking.id, loadList)">
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit fa-lg"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,203 +1,80 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import CustomerRegistrationForm from "@/components/forms/auth/CustomerRegistrationForm.vue";
|
||||
import { getDepartmentsGuest } from "@/components/pagination/departmentTabs.vue";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const departments = ref([]);
|
||||
const departmentsLoading = ref(true);
|
||||
|
||||
const visibleDepartments = computed(() => departments.value.slice(0, 14));
|
||||
const getDepartmentAddress = (department) => {
|
||||
const street = department?.address || "";
|
||||
const cityLine = [department?.zip, department?.city].filter(Boolean).join(" ");
|
||||
return [street, cityLine].filter(Boolean).join(", ");
|
||||
};
|
||||
|
||||
const getDepartmentLaneText = (laneCount) => {
|
||||
if (laneCount <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (locale.value?.toLowerCase().startsWith("da")) {
|
||||
return laneCount === 1 ? "1 vaskebane" : `${laneCount} vaskebaner`;
|
||||
}
|
||||
const base = t("self_wash.wash_lane");
|
||||
return laneCount === 1 ? `1 ${base}` : `${laneCount} ${base}s`;
|
||||
};
|
||||
|
||||
const getSelfWashText = (department) => {
|
||||
if (!department?.self_serve_enabled) {
|
||||
return "";
|
||||
}
|
||||
if (locale.value?.toLowerCase().startsWith("da")) {
|
||||
return "Tilbyder selvvask";
|
||||
}
|
||||
return `${t("self_wash.available")} ${t("nav.self_wash").toLowerCase()}`;
|
||||
};
|
||||
|
||||
const getNavigationUrl = (department) => {
|
||||
const lat = department?.latitude;
|
||||
const lng = department?.longitude;
|
||||
if (lat && lng) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${lat},${lng}`)}`;
|
||||
}
|
||||
const address = getDepartmentAddress(department);
|
||||
if (!address) {
|
||||
return "";
|
||||
}
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
departments.value = await getDepartmentsGuest({ include_lanes: true });
|
||||
} catch (_error) {
|
||||
departments.value = [];
|
||||
} finally {
|
||||
departmentsLoading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="qr-page">
|
||||
<div class="qr-page__container">
|
||||
<div class="hero-card">
|
||||
<section class="hero-card" aria-labelledby="qr-new-customer-title">
|
||||
<p class="hero-card__eyebrow">{{ $t("customer_creation.pre_title") }}</p>
|
||||
<h1>{{ $t("customer_creation.customer.title") }}</h1>
|
||||
<h1 id="qr-new-customer-title">{{ $t("customer_creation.customer.title") }}</h1>
|
||||
<p class="hero-card__lead">{{ $t("customer_creation.customer.intro") }}</p>
|
||||
<ul class="hero-card__benefits">
|
||||
<li>{{ $t("customer_creation.customer.benefit_portal") }}</li>
|
||||
<li>{{ $t("customer_creation.customer.benefit_credit") }}</li>
|
||||
<li>{{ $t("customer_creation.customer.benefit_wash") }}</li>
|
||||
<li>
|
||||
<span class="hero-card__benefit-title">{{ $t("customer_creation.customer.benefit_wash") }}</span>
|
||||
<span>{{ $t("customer_creation.customer.benefit_wash_desc") }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="hero-card__benefit-title">{{ $t("customer_creation.customer.benefit_portal") }}</span>
|
||||
<span>{{ $t("customer_creation.customer.benefit_portal_desc") }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="hero-card__benefit-title">{{ $t("customer_creation.customer.benefit_credit") }}</span>
|
||||
<span>{{ $t("customer_creation.customer.benefit_credit_desc") }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="hero-card__details">
|
||||
<li>{{ $t("customer_creation.customer.benefit_portal_desc") }}</li>
|
||||
<li>{{ $t("customer_creation.customer.benefit_credit_desc") }}</li>
|
||||
<li>{{ $t("customer_creation.customer.benefit_wash_desc") }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-card">
|
||||
<h2>{{ $t("customer_creation.customer.title") }}</h2>
|
||||
<section class="form-card" aria-labelledby="qr-new-customer-form-title">
|
||||
<h2 id="qr-new-customer-form-title">{{ $t("customer_creation.customer.title") }}</h2>
|
||||
<CustomerRegistrationForm />
|
||||
<p class="form-card__footer">
|
||||
<router-link :to="{ name: 'login' }">{{ $t("auth.login") }}</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>{{ $t("common.departments") }}</h3>
|
||||
<p class="info-card__intro">{{ $t("about_us.solutions.customer.portal_title") }}</p>
|
||||
<p v-if="departmentsLoading" class="info-card__status">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="!visibleDepartments.length" class="info-card__status">{{ $t("common.not_available") }}</p>
|
||||
<div v-else class="department-grid">
|
||||
<article v-for="department in visibleDepartments" :key="department.id" class="department-card">
|
||||
<div class="department-card__header">
|
||||
<span class="department-card__badge">
|
||||
<i class="fas fa-building" aria-hidden="true"></i>
|
||||
</span>
|
||||
<h4 class="department-card__title">{{ department.name }}</h4>
|
||||
</div>
|
||||
<p class="department-card__line">
|
||||
<i class="fas fa-map-marker-alt" aria-hidden="true"></i>
|
||||
<span>{{ getDepartmentAddress(department) || $t("common.not_available") }}</span>
|
||||
</p>
|
||||
<p v-if="department.phone" class="department-card__line">
|
||||
<i class="fas fa-phone-alt" aria-hidden="true"></i>
|
||||
<span>{{ department.phone }}</span>
|
||||
</p>
|
||||
<div class="department-card__footer">
|
||||
<div class="department-card__meta">
|
||||
<div v-if="(department.lanes?.length ?? 0) > 0" class="department-card__meta-item">
|
||||
<i class="fas fa-road" aria-hidden="true"></i>
|
||||
<span>{{ getDepartmentLaneText(department.lanes.length) }}</span>
|
||||
</div>
|
||||
<div v-if="department.self_serve_enabled" class="department-card__meta-item">
|
||||
<i class="fas fa-soap" aria-hidden="true"></i>
|
||||
<span>{{ getSelfWashText(department) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="department-card__actions">
|
||||
<a
|
||||
v-if="getNavigationUrl(department)"
|
||||
class="department-card__nav"
|
||||
:href="getNavigationUrl(department)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i class="fas fa-location-arrow" aria-hidden="true"></i>
|
||||
<span>{{ $t("common.open") }} Maps</span>
|
||||
<i class="fas fa-arrow-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>{{ $t("about_us.solutions.title") }}</h3>
|
||||
<div class="feature-grid">
|
||||
<article class="feature-grid__item">
|
||||
<span class="feature-grid__icon">01</span>
|
||||
<h4>{{ $t("customer_creation.customer.benefit_portal") }}</h4>
|
||||
<p>{{ $t("customer_creation.customer.benefit_portal_desc") }}</p>
|
||||
</article>
|
||||
<article class="feature-grid__item">
|
||||
<span class="feature-grid__icon">02</span>
|
||||
<h4>{{ $t("customer_creation.customer.benefit_credit") }}</h4>
|
||||
<p>{{ $t("customer_creation.customer.benefit_credit_desc") }}</p>
|
||||
</article>
|
||||
<article class="feature-grid__item">
|
||||
<span class="feature-grid__icon">03</span>
|
||||
<h4>{{ $t("customer_creation.customer.benefit_wash") }}</h4>
|
||||
<p>{{ $t("customer_creation.customer.benefit_wash_desc") }}</p>
|
||||
</article>
|
||||
<article class="feature-grid__item">
|
||||
<span class="feature-grid__icon">04</span>
|
||||
<h4>{{ $t("about_us.solutions.customer.portal_item5") }}</h4>
|
||||
<p>{{ $t("about_us.solutions.customer.portal_item3") }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-page {
|
||||
padding: clamp(16px, 4vw, 40px) 12px 48px;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
padding: clamp(12px, 4vw, 40px) 12px calc(32px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.qr-page__container {
|
||||
max-width: 1240px;
|
||||
width: min(100%, 1040px);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.form-card {
|
||||
background: #13324c;
|
||||
color: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 22px 18px;
|
||||
min-width: 0;
|
||||
border-radius: 14px;
|
||||
padding: 18px 14px;
|
||||
box-shadow: 0 12px 28px rgba(19, 50, 76, 0.12);
|
||||
}
|
||||
|
||||
.info-card {
|
||||
.hero-card {
|
||||
background: #13324c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: #ffffff;
|
||||
color: #13324c;
|
||||
border-radius: 16px;
|
||||
padding: 20px 18px;
|
||||
border: 1px solid #d4dbe7;
|
||||
border: 1px solid #d8e0ec;
|
||||
}
|
||||
|
||||
.hero-card h1 {
|
||||
margin: 8px 0 10px;
|
||||
font-size: clamp(1.7rem, 7vw, 2.4rem);
|
||||
line-height: 1.08;
|
||||
font-size: clamp(1.6rem, 8vw, 2.4rem);
|
||||
line-height: 1.12;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hero-card__eyebrow {
|
||||
@@ -211,43 +88,70 @@ onMounted(async () => {
|
||||
.hero-card__lead {
|
||||
margin: 0;
|
||||
opacity: 0.95;
|
||||
line-height: 1.4;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.hero-card__benefits {
|
||||
margin: 14px 0 0;
|
||||
margin: 16px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hero-card__benefits li {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.2;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.hero-card__details {
|
||||
margin: 14px 0 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
line-height: 1.35;
|
||||
opacity: 0.95;
|
||||
.hero-card__benefit-title {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-card h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 6vw, 1.95rem);
|
||||
line-height: 1.16;
|
||||
}
|
||||
|
||||
.form-card :deep(.truckwash-form) {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-top: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.form-card :deep(.form-field),
|
||||
.form-card :deep(input),
|
||||
.form-card :deep(.truckwash-submit) {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.form-card :deep(input) {
|
||||
box-sizing: border-box;
|
||||
min-height: 46px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-card :deep(.truckwash-submit) {
|
||||
min-height: 48px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.form-card :deep(.truckwash-submit .button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-card__footer {
|
||||
@@ -257,253 +161,31 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.form-card__footer a {
|
||||
color: #fff;
|
||||
color: #13324c;
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.info-card__intro {
|
||||
margin: 8px 0 0;
|
||||
color: #3e526e;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-card__status {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.department-grid {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.department-card {
|
||||
background: linear-gradient(156deg, #ffffff 0%, #f4f8ff 55%, #eef4ff 100%);
|
||||
border: 1px solid #cddcf2;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 10px 28px rgba(17, 44, 70, 0.1);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.department-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(130deg, rgba(19, 50, 76, 0.3), rgba(76, 178, 227, 0.25));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.department-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 18px 34px rgba(17, 44, 70, 0.16);
|
||||
border-color: #b8cdec;
|
||||
}
|
||||
|
||||
.department-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.department-card__badge {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #13324c, #1d4b6f);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 6px 14px rgba(19, 50, 76, 0.28);
|
||||
}
|
||||
|
||||
.department-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
color: #153650;
|
||||
}
|
||||
|
||||
.department-card__line {
|
||||
margin: 10px 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
line-height: 1.35;
|
||||
font-size: 0.9rem;
|
||||
color: #274767;
|
||||
}
|
||||
|
||||
.department-card__line i {
|
||||
color: #1e5b88;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.department-card__meta {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.department-card__meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
color: #20496a;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.department-card__meta-item i {
|
||||
color: #13324c;
|
||||
}
|
||||
|
||||
.department-card__footer {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e5ebf5;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.department-card__actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.department-card__nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
border-radius: 11px;
|
||||
border: 1px solid #9fbce2;
|
||||
background: linear-gradient(135deg, #f8fbff, #edf4ff);
|
||||
color: #13324c;
|
||||
padding: 9px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.department-card__nav:hover {
|
||||
background: linear-gradient(135deg, #edf4ff, #e5efff);
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feature-grid__item {
|
||||
background: linear-gradient(135deg, #f5f9ff, #edf4ff);
|
||||
border: 1px solid #d6e3f7;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-grid__item::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
bottom: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: radial-gradient(circle, rgba(22, 126, 190, 0.15), transparent 68%);
|
||||
}
|
||||
|
||||
.feature-grid__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: #13324c;
|
||||
color: #fff;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.feature-grid__item h4 {
|
||||
margin: 10px 0 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.feature-grid__item p {
|
||||
margin: 0;
|
||||
color: #345070;
|
||||
line-height: 1.35;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@media (min-width: 768px) {
|
||||
.qr-page {
|
||||
padding-top: 36px;
|
||||
}
|
||||
|
||||
.qr-page__container {
|
||||
grid-template-columns: minmax(420px, 1.05fr) minmax(440px, 1fr);
|
||||
gap: 24px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.92fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.form-card {
|
||||
min-height: 100%;
|
||||
border-radius: 18px;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
grid-column: 1 / -1;
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.department-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-card,
|
||||
.form-card {
|
||||
border-radius: 14px;
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.hero-card__details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.department-card__footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
.hero-card {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,6 +63,9 @@ test.describe("Admin bookings mobile", () => {
|
||||
.first();
|
||||
|
||||
await expect(bookingCard).toBeVisible();
|
||||
await expect(bookingCard.locator(".button.is-warning")).toHaveCount(0);
|
||||
await expect(bookingCard).not.toContainText("Marker som fuldf\u00f8rt");
|
||||
await expect(bookingCard).not.toContainText("Complete booking");
|
||||
|
||||
const border = await bookingCard.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
|
||||
@@ -515,12 +515,10 @@ test.describe("Admin POS drafts", () => {
|
||||
|
||||
const settingsRoot = page.getByTestId("pos-order-list-settings-56625");
|
||||
const settingsTrigger = settingsRoot.locator(".dropdown-trigger > button");
|
||||
const dropdown = settingsRoot.locator(".dropdown");
|
||||
const menu = settingsRoot.locator(".dropdown-content");
|
||||
|
||||
await expect(settingsTrigger).toBeVisible();
|
||||
await settingsTrigger.click();
|
||||
await expect(dropdown).toHaveClass(/is-up/);
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const viewport = page.viewportSize();
|
||||
|
||||
@@ -78,8 +78,8 @@ async function createDisposableOrder(page: Page) {
|
||||
const reg1 = `PW${uniqueSuffix}`;
|
||||
const reference = `E2E-${uniqueSuffix}`;
|
||||
|
||||
await page.goto("/admin/12/modules/pos?step=1");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
await page.goto("/admin/12/modules/pos?step=1", { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await page.locator("#reg_1").fill(reg1);
|
||||
await page.locator("#reference").fill(reference);
|
||||
@@ -92,6 +92,7 @@ async function createDisposableOrder(page: Page) {
|
||||
|
||||
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
||||
await expect(page).toHaveURL(/\/admin\/12\/modules\/pos\?id=\d+&customer_id=12345679&step=2/);
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const url = new URL(page.url());
|
||||
const orderId = Number(url.searchParams.get("id"));
|
||||
@@ -147,13 +148,14 @@ async function waitForSwalToClose(page: Page) {
|
||||
|
||||
async function openOrderSettings(page: Page, orderId: number) {
|
||||
await page.goto(`/admin/12/modules/pos/orders/${orderId}`);
|
||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 20_000 });
|
||||
await clickVisibleTestId(page, "pos-order-tab-settings");
|
||||
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
|
||||
}
|
||||
|
||||
async function revisitCurrentPage(page: Page) {
|
||||
await page.goto(page.url(), { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function reloadOrderSettings(page: Page, orderId: number) {
|
||||
@@ -229,26 +231,63 @@ async function createInvoiceCollectionFromPicker(page: Page, closedAt: string) {
|
||||
await createResponse;
|
||||
}
|
||||
|
||||
async function createInvoiceCollectionFromAssignmentModal(page: Page, invoiceCollectionId: number) {
|
||||
const createRequest = page.waitForRequest((request) => {
|
||||
return request.method() === "POST" && request.url().includes("/collected-invoices");
|
||||
});
|
||||
const createResponse = page.waitForResponse((response) => {
|
||||
return response.request().method() === "POST" && response.url().includes("/collected-invoices");
|
||||
});
|
||||
|
||||
await page.getByTestId("draft-order-create-invoice-collection").click();
|
||||
await createRequest;
|
||||
await createResponse;
|
||||
await expect(page.getByTestId(`draft-order-invoice-collection-option-${invoiceCollectionId}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOrderInvoiceCollectionSuccess(page: Page) {
|
||||
const successPopup = page.locator(".swal2-popup");
|
||||
await expect(successPopup).toContainText(/Faktura samling ændret/i, { timeout: 10000 });
|
||||
await expect(successPopup).toBeHidden({ timeout: 4000 });
|
||||
}
|
||||
|
||||
async function changeOrderCustomer(page: Page, customerNumber: string, closedAt: string) {
|
||||
async function changeOrderCustomer(page: Page, customerNumber: string, invoiceCollectionId: number) {
|
||||
await getOrderSettingsEditButton(page, "customer_id").click();
|
||||
|
||||
const customerPopup = page.locator(".swal2-popup");
|
||||
await expect(customerPopup).toBeVisible();
|
||||
await customerPopup.locator("#pos_select_customer_input").fill(customerNumber);
|
||||
const modal = page.getByTestId("draft-order-assign-customer-modal");
|
||||
await expect(modal).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("draft-order-assign-customer-search").fill(customerNumber);
|
||||
|
||||
const customerResult = customerPopup.locator(".customer-drop-down-select").first();
|
||||
await expect(customerResult).toBeVisible();
|
||||
const customerResult = page.getByTestId(`draft-order-customer-option-${customerNumber}`);
|
||||
await expect(customerResult).toBeVisible({ timeout: 10_000 });
|
||||
await customerResult.click();
|
||||
await expect(customerPopup.locator("#pos_select_customer_input")).toHaveCount(0, { timeout: 10000 });
|
||||
await expect(page.getByTestId("draft-order-selected-customer")).toContainText(customerNumber);
|
||||
|
||||
await createInvoiceCollectionFromPicker(page, closedAt);
|
||||
await waitForOrderInvoiceCollectionSuccess(page);
|
||||
await createInvoiceCollectionFromAssignmentModal(page, invoiceCollectionId);
|
||||
|
||||
const customerUpdate = page.waitForRequest((request) => {
|
||||
if (request.method() !== "PUT" || !request.url().includes("/orders")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = request.postDataJSON();
|
||||
return String(body?.customer_id) === customerNumber;
|
||||
});
|
||||
const invoiceCollectionUpdate = page.waitForRequest((request) => {
|
||||
if (request.method() !== "PUT" || !request.url().includes("/orders")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = request.postDataJSON();
|
||||
return Number(body?.invoice_collection_id) === invoiceCollectionId;
|
||||
});
|
||||
|
||||
await page.getByTestId("draft-order-assign-submit").click();
|
||||
await customerUpdate;
|
||||
await invoiceCollectionUpdate;
|
||||
await expect(modal).toBeHidden({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function changeOrderInvoiceCollection(page: Page, closedAt: string) {
|
||||
@@ -1368,6 +1407,8 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
});
|
||||
|
||||
test("persists department, created_at, and invoice inclusion changes through per-field modals", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const { orderId } = await createDisposableOrder(page);
|
||||
const updatedCreatedAt = "2026-04-09T13:37";
|
||||
const helper = page.getByTestId("pos-order-settings-include-helper");
|
||||
@@ -1416,7 +1457,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
await openOrderSettings(page, orderId);
|
||||
const initialUrl = page.url();
|
||||
|
||||
await changeOrderCustomer(page, "999", "2026-04-30");
|
||||
await changeOrderCustomer(page, "999", 300);
|
||||
await expect(page).toHaveURL(initialUrl);
|
||||
await expect(getOrderSettingsField(page, "customer_id")).toContainText("999");
|
||||
await expect(getOrderSettingsField(page, "invoice_collection_id")).toContainText("300");
|
||||
@@ -1684,16 +1725,22 @@ test.describe("Admin POS Orders - draft transaction customer", () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(POS_BOOT_URL);
|
||||
await expect(page.getByTestId("pos-draft-customer-quick-action")).toBeVisible();
|
||||
await page.locator("#reg_1").fill("DRAFT01");
|
||||
|
||||
await page.getByTestId("pos-draft-customer-quick-action").click();
|
||||
await expect(page.locator(".field.has-addons input[disabled]").last()).toHaveValue(DRAFT_TRANSACTION_CUSTOMER_NAME);
|
||||
const draftCustomerAction = page.getByTestId("pos-draft-customer-inline-action");
|
||||
await expect(draftCustomerAction).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Clear" }).click();
|
||||
await draftCustomerAction.click();
|
||||
const selectedCustomerInput = page.locator(".field.has-addons input[disabled]").last();
|
||||
await expect(selectedCustomerInput).toHaveValue(DRAFT_TRANSACTION_CUSTOMER_NAME);
|
||||
|
||||
await selectedCustomerInput
|
||||
.locator("..")
|
||||
.locator("..")
|
||||
.getByRole("button", { name: /Clear|Ryd/ })
|
||||
.click();
|
||||
await selectStepOneCustomer(page, 12345679);
|
||||
await expect(page.locator(".field.has-addons input[disabled]").last()).toHaveValue(
|
||||
/\(TEST\) Pleno Vognmandsforretning/
|
||||
);
|
||||
await expect(selectedCustomerInput).toHaveValue(/\(TEST\) Pleno Vognmandsforretning/);
|
||||
});
|
||||
|
||||
test("opens the draft customer assignment modal from the blocked warnings and restores export actions after selecting a real customer", async ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
import { bookingTestData, loginAsOperator } from "./fixtures";
|
||||
import { bookingTestData } from "./fixtures";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
type GoalProgressBucket = {
|
||||
count: number;
|
||||
@@ -201,6 +202,45 @@ const setupGoalsApiMock = async (page: Page, departmentId: number) => {
|
||||
|
||||
test.describe("Admin Module - Goals", () => {
|
||||
const getGoalsUrl = (departmentId: string) => `/admin/${departmentId}/modules/goals`;
|
||||
const getGoalsPermissions = (departmentId: number) => [
|
||||
"admin",
|
||||
`department_access_${departmentId}`,
|
||||
"list_department_goals",
|
||||
"goals_department_create",
|
||||
"goals_department_progress_alert_test",
|
||||
];
|
||||
|
||||
const buildOperatorSessionData = (permissions: string[]) => ({
|
||||
id: 11,
|
||||
customer_number: 0,
|
||||
group_id: 1,
|
||||
email: "operator@example.com",
|
||||
phone: {
|
||||
number: "12345678",
|
||||
country_code: 45,
|
||||
},
|
||||
notifications: {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: true,
|
||||
sms_notifications_enabled: false,
|
||||
},
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
display_name: "E2E Operator",
|
||||
permissions,
|
||||
economic_customer: [],
|
||||
two_factor_enabled: false,
|
||||
});
|
||||
|
||||
const installGoalsSessionRoute = async (page: Page, permissions: string[]) => {
|
||||
await page.route("**/auth/session", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: buildOperatorSessionData(permissions) }),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const openGoalAction = async (page: Page, label: string, actionLabel: string) => {
|
||||
const card = page.locator(".goal-card", { hasText: label }).first();
|
||||
@@ -244,45 +284,14 @@ test.describe("Admin Module - Goals", () => {
|
||||
) => {
|
||||
const removeSet = new Set((options.remove || []).map((permission) => String(permission)));
|
||||
const addList = (options.add || []).map((permission) => String(permission));
|
||||
const departmentId = bookingTestData.departmentId;
|
||||
const currentPermissions = getGoalsPermissions(departmentId);
|
||||
|
||||
await page.route("**/auth/session", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const status = response.status();
|
||||
const headers = response.headers();
|
||||
const body = await response.json();
|
||||
const responseDataRoot = body && typeof body === "object" ? body : {};
|
||||
const nextPermissions = Array.from(
|
||||
new Set(currentPermissions.filter((permission) => !removeSet.has(permission)).concat(addList))
|
||||
);
|
||||
|
||||
const nestedData = responseDataRoot?.data?.data;
|
||||
const flatData = responseDataRoot?.data;
|
||||
const sessionPayload =
|
||||
nestedData && typeof nestedData === "object"
|
||||
? nestedData
|
||||
: flatData && typeof flatData === "object"
|
||||
? flatData
|
||||
: responseDataRoot;
|
||||
|
||||
const currentPermissions = Array.isArray(sessionPayload?.permissions)
|
||||
? sessionPayload.permissions.map((permission) => String(permission))
|
||||
: [];
|
||||
|
||||
const nextPermissions = Array.from(
|
||||
new Set(currentPermissions.filter((permission) => !removeSet.has(permission)).concat(addList))
|
||||
);
|
||||
|
||||
if (nestedData && typeof nestedData === "object") {
|
||||
responseDataRoot.data.data.permissions = nextPermissions;
|
||||
} else if (flatData && typeof flatData === "object") {
|
||||
responseDataRoot.data.permissions = nextPermissions;
|
||||
} else {
|
||||
responseDataRoot.permissions = nextPermissions;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status,
|
||||
headers,
|
||||
body: JSON.stringify(responseDataRoot),
|
||||
});
|
||||
});
|
||||
await installGoalsSessionRoute(page, nextPermissions);
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
@@ -292,9 +301,16 @@ test.describe("Admin Module - Goals", () => {
|
||||
"Goals admin suite is stabilized for Chromium in this environment."
|
||||
);
|
||||
|
||||
await loginAsOperator(page);
|
||||
|
||||
const departmentId = bookingTestData.departmentId;
|
||||
const goalsPermissions = getGoalsPermissions(departmentId);
|
||||
const goalsSessionData = buildOperatorSessionData(goalsPermissions);
|
||||
await seedAuthenticatedState(page, "admin-goals-token");
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: goalsPermissions,
|
||||
sessionData: goalsSessionData,
|
||||
});
|
||||
await installGoalsSessionRoute(page, goalsPermissions);
|
||||
await setupGoalsApiMock(page, departmentId);
|
||||
|
||||
await page.goto(getGoalsUrl(String(departmentId)));
|
||||
@@ -338,6 +354,23 @@ test.describe("Admin Module - Goals", () => {
|
||||
test("updates progress target when timeframe changes for cadence goals", async ({ page }) => {
|
||||
const departmentId = bookingTestData.departmentId;
|
||||
const nowIso = new Date().toISOString();
|
||||
await page.addInitScript(`
|
||||
(() => {
|
||||
const fixedNow = new Date("2026-04-04T10:00:00.000Z").getTime();
|
||||
const RealDate = Date;
|
||||
class FixedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
super(...(args.length ? args : [fixedNow]));
|
||||
}
|
||||
static now() {
|
||||
return fixedNow;
|
||||
}
|
||||
}
|
||||
FixedDate.UTC = RealDate.UTC;
|
||||
FixedDate.parse = RealDate.parse;
|
||||
window.Date = FixedDate;
|
||||
})();
|
||||
`);
|
||||
|
||||
const cadenceGoal: MockGoal = {
|
||||
id: 301,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi } from "./support/network.js";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
import { containsSuspiciousEncoding } from "../../scripts/text-encoding.mjs";
|
||||
|
||||
function json(body, status = 200) {
|
||||
@@ -475,7 +475,10 @@ test.describe("Economic queue async export workflow", () => {
|
||||
});
|
||||
|
||||
test("collected invoice management shows a blocked state for the configured draft customer", async ({ page }) => {
|
||||
await seedAuthenticatedState(page, "economic-queue-draft-customer-token");
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
economic: {
|
||||
@@ -487,7 +490,9 @@ test.describe("Economic queue async export workflow", () => {
|
||||
|
||||
await openHarness(page);
|
||||
await page.evaluate(async () => {
|
||||
const { SessionUser } = await import("/src/components/session/token/SessionUser.vue");
|
||||
const draftCustomerModule = await import("/src/composables/useDraftTransactionCustomer.js");
|
||||
await SessionUser.getSessionData();
|
||||
draftCustomerModule.setDraftTransactionCustomerNumber(6001);
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 77 KiB |
@@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Assign Draft Order Customer Modal Harness</title>
|
||||
<link rel="stylesheet" href="/bulma/css/bulma.min.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import { createApp, h } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import "bulma/css/bulma.min.css";
|
||||
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
|
||||
@@ -47,25 +47,21 @@ const i18n = createI18n({
|
||||
});
|
||||
|
||||
createApp({
|
||||
components: {
|
||||
AssignDraftOrderCustomerModal,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
order: {
|
||||
id: 56679,
|
||||
department_id: 1,
|
||||
invoice_collection_id: null,
|
||||
},
|
||||
setup() {
|
||||
const order = {
|
||||
id: 56679,
|
||||
department_id: 1,
|
||||
invoice_collection_id: null,
|
||||
};
|
||||
const noop = () => {};
|
||||
|
||||
return () =>
|
||||
h(AssignDraftOrderCustomerModal, {
|
||||
order,
|
||||
onClose: noop,
|
||||
onAssigned: noop,
|
||||
});
|
||||
},
|
||||
template: `
|
||||
<AssignDraftOrderCustomerModal
|
||||
:order="order"
|
||||
@close="() => {}"
|
||||
@assigned="() => {}"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
.use(i18n)
|
||||
.mount("#app");
|
||||
|
||||
@@ -8,7 +8,8 @@ const LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "locales");
|
||||
|
||||
const readLocale = (locale: (typeof ACTIVE_LOCALES)[number]) => {
|
||||
const absolutePath = path.join(LOCALES_DIRECTORY, `${locale}.json`);
|
||||
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as Record<string, unknown>;
|
||||
const content = fs.readFileSync(absolutePath, "utf8").replace(/^\uFEFF/, "");
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const hasKeyPath = (value: unknown, keyPath: string): boolean => {
|
||||
|
||||
@@ -434,7 +434,7 @@ async function openPeriodView(page) {
|
||||
async function setEntireMarchPeriod(page) {
|
||||
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
|
||||
await expect(dateInputs.first()).toBeVisible();
|
||||
await dateInputs.nth(0).fill("2026-03-03");
|
||||
await dateInputs.nth(0).fill("2026-03-01");
|
||||
await dateInputs.nth(0).dispatchEvent("change");
|
||||
await dateInputs.nth(1).fill("2026-03-31");
|
||||
await dateInputs.nth(1).dispatchEvent("change");
|
||||
@@ -663,8 +663,61 @@ test.describe("Invoicing period tab", () => {
|
||||
await expect(page).toHaveURL(/activeTab=period/);
|
||||
});
|
||||
|
||||
test("@smoke period partial month warning selects the whole calendar month", async ({ page }, testInfo) => {
|
||||
test.skip(/mobile/i.test(testInfo.project.name), "Desktop date inputs are exercised by this warning-link flow.");
|
||||
|
||||
const { periodRequests } = await openPeriodView(page);
|
||||
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
|
||||
await expect(dateInputs.first()).toBeVisible();
|
||||
|
||||
await dateInputs.nth(0).fill("2026-05-04");
|
||||
await dateInputs.nth(0).dispatchEvent("change");
|
||||
await dateInputs.nth(1).fill("2026-05-05");
|
||||
await dateInputs.nth(1).dispatchEvent("change");
|
||||
|
||||
const requestCountBeforeClick = periodRequests.length;
|
||||
await page.getByTestId("date-period-set-entire-month").click();
|
||||
|
||||
await expect(dateInputs.nth(0)).toHaveValue("2026-05-01");
|
||||
await expect(dateInputs.nth(1)).toHaveValue("2026-05-31");
|
||||
await expect.poll(() => periodRequests.length).toBeGreaterThan(requestCountBeforeClick);
|
||||
await expect
|
||||
.poll(() => periodRequests.at(-1))
|
||||
.toEqual({
|
||||
dateFrom: "2026-05-01",
|
||||
dateTo: "2026-05-31",
|
||||
});
|
||||
});
|
||||
|
||||
test("@smoke period month shortcuts select whole calendar months", async ({ page }, testInfo) => {
|
||||
test.skip(
|
||||
!/desktop/i.test(testInfo.project.name),
|
||||
"Desktop month shortcuts are not rendered in compact date selector layouts."
|
||||
);
|
||||
|
||||
await page.clock.setFixedTime(new Date("2026-05-04T10:00:00.000Z"));
|
||||
const { periodRequests } = await openPeriodView(page);
|
||||
const initialRequestCount = periodRequests.length;
|
||||
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
|
||||
|
||||
await page.getByRole("button", { name: /last month|sidste måned/i }).click();
|
||||
|
||||
await expect(dateInputs.nth(0)).toHaveValue("2026-04-01");
|
||||
await expect(dateInputs.nth(1)).toHaveValue("2026-04-30");
|
||||
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
|
||||
await expect
|
||||
.poll(() => periodRequests.at(-1))
|
||||
.toEqual({
|
||||
dateFrom: "2026-04-01",
|
||||
dateTo: "2026-04-30",
|
||||
});
|
||||
});
|
||||
|
||||
test("@smoke period view reload button triggers a fresh period query", async ({ page }, testInfo) => {
|
||||
test.skip(/mobile/i.test(testInfo.project.name), "Reload button is not rendered in mobile date selector layout.");
|
||||
test.skip(
|
||||
!/desktop/i.test(testInfo.project.name),
|
||||
"Reload button is not rendered in compact date selector layouts."
|
||||
);
|
||||
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => {
|
||||
@@ -683,6 +736,104 @@ test.describe("Invoicing period tab", () => {
|
||||
expect(pageErrors).not.toEqual(expect.arrayContaining([expect.stringContaining("reading 'types'")]));
|
||||
});
|
||||
|
||||
test("@smoke period monthly split posts selected range and refreshes period data", async ({ page }, testInfo) => {
|
||||
test.skip(
|
||||
!/desktop/i.test(testInfo.project.name),
|
||||
"Monthly split button is not rendered in compact date selector layouts."
|
||||
);
|
||||
|
||||
const { periodRequests } = await openPeriodView(page);
|
||||
const splitRequests = [];
|
||||
const initialRequestCount = periodRequests.length;
|
||||
|
||||
await page.route("**/collected-invoices/split-by-month**", async (route) => {
|
||||
if (
|
||||
route.request().method() !== "POST" ||
|
||||
!matchesApiPath(route.request().url(), "/collected-invoices/split-by-month")
|
||||
) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(route.request().postData() || "{}");
|
||||
splitRequests.push(payload);
|
||||
if (payload.preview) {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
preview: true,
|
||||
processed_count: 2,
|
||||
changed_count: 1,
|
||||
skipped_count: 1,
|
||||
changed: [
|
||||
{
|
||||
invoice_collection_id: 101,
|
||||
months: [
|
||||
{
|
||||
month: "2026-03",
|
||||
order_count: 2,
|
||||
will_create_collection: false,
|
||||
target_invoice_collection_id: 101,
|
||||
},
|
||||
{
|
||||
month: "2026-04",
|
||||
order_count: 1,
|
||||
will_create_collection: true,
|
||||
target_invoice_collection_id: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
skipped: [
|
||||
{
|
||||
invoice_collection_id: 202,
|
||||
message: "Invoice collection already belongs to one month",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
preview: false,
|
||||
processed_count: 2,
|
||||
changed_count: 1,
|
||||
skipped_count: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.getByTestId("invoicing-period-split-by-month-button").click();
|
||||
await expect(page.getByText(/Preview monthly split|Forhåndsvis månedsopdeling/i)).toBeVisible();
|
||||
await expect(
|
||||
page.locator(".swal2-html-container strong").filter({ hasText: /Collection #101|Fakturasamling #101/i })
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".swal2-html-container td").filter({ hasText: /^2026-04$/ })).toBeVisible();
|
||||
await expect(page.getByText(/Create new collection|Opret ny fakturasamling/i)).toBeVisible();
|
||||
await page.getByRole("button", { name: /apply split|udfør opdeling/i }).click();
|
||||
|
||||
await expect.poll(() => splitRequests.length).toBe(2);
|
||||
expect(splitRequests[0]).toEqual({
|
||||
dateFrom: periodRequests[0]?.dateFrom,
|
||||
dateTo: periodRequests[0]?.dateTo,
|
||||
preview: true,
|
||||
});
|
||||
expect(splitRequests[1]).toEqual({
|
||||
dateFrom: periodRequests[0]?.dateFrom,
|
||||
dateTo: periodRequests[0]?.dateTo,
|
||||
preview: false,
|
||||
});
|
||||
|
||||
await expect(page.getByText(/Processed 2 invoice collections|Behandlede 2 fakturasamlinger/i)).toBeVisible();
|
||||
await page.getByRole("button", { name: "OK" }).click();
|
||||
await expect.poll(() => periodRequests.length).toBeGreaterThan(initialRequestCount);
|
||||
});
|
||||
|
||||
test("@smoke period view shows a queued CTA when backend queue metadata blocks invoicing", async ({ page }) => {
|
||||
const periodRequests = [];
|
||||
await seedInvoicesPage(page);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
import { createPosFixture, mockApi, primeMockSession } from "./support/network.js";
|
||||
|
||||
const POS_PERMISSIONS = [
|
||||
"admin",
|
||||
@@ -10,6 +10,7 @@ const POS_PERMISSIONS = [
|
||||
"list_customer_attributes",
|
||||
"get_custom_prices_other",
|
||||
];
|
||||
const POS_STEP_TIMEOUT = 20_000;
|
||||
|
||||
function json(body, status = 200) {
|
||||
return {
|
||||
@@ -20,17 +21,12 @@ function json(body, status = 200) {
|
||||
}
|
||||
|
||||
async function primeOperatorSession(page, token = "pos-customer-rules-token") {
|
||||
await seedAuthenticatedState(page, token);
|
||||
const sessionRequest = page.waitForResponse((response) => {
|
||||
return response.request().method() === "GET" && response.url().includes("/auth/session");
|
||||
});
|
||||
await page.goto("/login");
|
||||
await sessionRequest;
|
||||
await primeMockSession(page, { token, bootPath: "/login" });
|
||||
}
|
||||
|
||||
async function openPosAndSelectCustomer(page, customer) {
|
||||
await page.goto("/admin/12/modules/pos?step=1");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
|
||||
await page.locator("#reg_1").fill("AB12345");
|
||||
await page.locator("#pos_select_customer_input").fill(String(customer.customerNumber));
|
||||
@@ -70,7 +66,7 @@ test("rules tab reloads customer attributes when the panel becomes visible", asy
|
||||
await primeOperatorSession(page);
|
||||
|
||||
await page.goto("/admin/12/modules/pos?step=1");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
|
||||
await page.locator("#reg_1").fill("AB12345");
|
||||
await page.locator("#pos_select_customer_input").fill(String(customer.customerNumber));
|
||||
@@ -137,7 +133,7 @@ test("customer details renders top-level phone object when economic customer pay
|
||||
|
||||
await primeOperatorSession(page);
|
||||
await page.goto(`/admin/12/modules/pos?customer_id=${customer.customerNumber}&step=1`);
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await expect(page.locator(".pos-selected-customer__title").filter({ hasText: customer.name }).first()).toBeVisible();
|
||||
await expect.poll(() => usersCustomerInterceptCount).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
import { createPosFixture, mockApi, primeMockSession } from "./support/network.js";
|
||||
|
||||
const ORDER_ID = 54518;
|
||||
const DEPARTMENT_ID = 12;
|
||||
@@ -24,10 +24,21 @@ const SETUP_REQUIRED_MESSAGE =
|
||||
function suppressVueDevtoolsOverlay(page) {
|
||||
return page.addInitScript(() => {
|
||||
window.__TW_POS_STRIPE_EMAIL_POLLING_INTERVAL_MS__ = 250;
|
||||
const style = document.createElement("style");
|
||||
style.textContent =
|
||||
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
|
||||
document.documentElement.appendChild(style);
|
||||
const injectStyle = () => {
|
||||
const root = document.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.textContent =
|
||||
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
|
||||
root.appendChild(style);
|
||||
};
|
||||
if (document.documentElement) {
|
||||
injectStyle();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", injectStyle, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,17 +103,7 @@ function createHostedInvoice(orderId, overrides = {}) {
|
||||
}
|
||||
|
||||
async function primeSession(page, token = "desktop-card-token") {
|
||||
await seedAuthenticatedState(page, token);
|
||||
const sessionRequest = page
|
||||
.waitForResponse(
|
||||
(response) => {
|
||||
return response.request().method() === "GET" && response.url().includes("/auth/session");
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.catch(() => null);
|
||||
await page.goto("/login");
|
||||
await sessionRequest;
|
||||
await primeMockSession(page, { token });
|
||||
}
|
||||
|
||||
async function bootDesktopCardPayment(page, fixture, permissions = DESKTOP_POS_PERMISSIONS) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mockApi, primeMockSession } from "./support/network.js";
|
||||
|
||||
const API_HOST =
|
||||
/https?:\/\/(?:api\.truckwash\.io(?::\d+)?\/.*|localhost(?::\d+)?\/api\/.*|127\.0\.0\.1(?::\d+)?\/api\/.*)/i;
|
||||
const POS_STEP_TIMEOUT = 20_000;
|
||||
|
||||
function json(body, status = 200) {
|
||||
return {
|
||||
@@ -1021,7 +1022,7 @@ async function setupDesktopPosPage(page, fixture, { token = "pos-desktop-token",
|
||||
});
|
||||
|
||||
await page.goto("/admin/1/modules/pos");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
}
|
||||
|
||||
function getActiveDesktopModal(page) {
|
||||
@@ -1107,7 +1108,7 @@ test.describe("POS flow", () => {
|
||||
});
|
||||
|
||||
await page.goto("/admin/1/modules/pos?customer_id=12345&step=1");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await expect(page.getByTestId("pos-step-1").getByText("Nummerplader").first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
expect(invalidOrderRequests).toEqual([]);
|
||||
@@ -1643,7 +1644,7 @@ test.describe("POS flow", () => {
|
||||
});
|
||||
|
||||
await page.goto("/admin/1/modules/pos");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
|
||||
await page.locator("#reg_1").fill("AB12345");
|
||||
await expect(page.getByTestId("pos-step-1").getByText("Pleno Logistics").first()).toBeVisible({ timeout: 10_000 });
|
||||
@@ -1879,7 +1880,7 @@ test.describe("POS flow", () => {
|
||||
});
|
||||
|
||||
await page.goto("/admin/2/modules/pos");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
|
||||
await page.locator("#reg_1").fill("EC212");
|
||||
await expect(page.getByTestId("desktop-booked-icon-7011")).toBeVisible({ timeout: 10_000 });
|
||||
@@ -2960,7 +2961,6 @@ test.describe("POS flow", () => {
|
||||
|
||||
const activeBookingSelector = getActiveDesktopModal(page);
|
||||
await page.locator("#reg_1").fill("MULTITRL");
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
|
||||
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-step-2")).not.toBeVisible();
|
||||
@@ -2969,6 +2969,7 @@ test.describe("POS flow", () => {
|
||||
await activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8126").click();
|
||||
await expect(page.locator("#reg_1")).toHaveValue("TRACTORC");
|
||||
await expect(page.locator("#reg_2")).toHaveValue("MULTITRL");
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect.poll(() => fixture.ordersById[9300]?.reg_1 || null, { timeout: 10_000 }).toBe("TRACTORC");
|
||||
await expect.poll(() => fixture.ordersById[9300]?.reg_2 || null, { timeout: 10_000 }).toBe("MULTITRL");
|
||||
|
||||
@@ -366,6 +366,7 @@ async function dragAcrossCopyLastWash(page, offsetY = 48) {
|
||||
|
||||
async function longPressCopyLastWash(page, waitMs = 650) {
|
||||
const { trigger, clientX, clientY } = await getCopyLastWashTouchPoint(page);
|
||||
const triggerHandle = await trigger.elementHandle();
|
||||
|
||||
await trigger.dispatchEvent("pointerdown", {
|
||||
pointerType: "touch",
|
||||
@@ -373,11 +374,15 @@ async function longPressCopyLastWash(page, waitMs = 650) {
|
||||
clientY,
|
||||
});
|
||||
await page.waitForTimeout(waitMs);
|
||||
await trigger.dispatchEvent("pointerup", {
|
||||
pointerType: "touch",
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (triggerHandle) {
|
||||
await triggerHandle
|
||||
.dispatchEvent("pointerup", {
|
||||
pointerType: "touch",
|
||||
clientX,
|
||||
clientY,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function getAdditionalItemsTouchPoint(page) {
|
||||
@@ -422,6 +427,7 @@ async function dragAcrossAdditionalItems(page, offsetY = 48) {
|
||||
|
||||
async function longPressAdditionalItems(page, waitMs = 650) {
|
||||
const { trigger, clientX, clientY } = await getAdditionalItemsTouchPoint(page);
|
||||
const triggerHandle = await trigger.elementHandle();
|
||||
|
||||
await trigger.dispatchEvent("pointerdown", {
|
||||
pointerType: "touch",
|
||||
@@ -429,11 +435,15 @@ async function longPressAdditionalItems(page, waitMs = 650) {
|
||||
clientY,
|
||||
});
|
||||
await page.waitForTimeout(waitMs);
|
||||
await trigger.dispatchEvent("pointerup", {
|
||||
pointerType: "touch",
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (triggerHandle) {
|
||||
await triggerHandle
|
||||
.dispatchEvent("pointerup", {
|
||||
pointerType: "touch",
|
||||
clientX,
|
||||
clientY,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBookingHydration(page, { primaryId, addonProductIds = [] } = {}) {
|
||||
@@ -2983,11 +2993,21 @@ test.describe("POS mobile order flow", () => {
|
||||
});
|
||||
|
||||
test("shows loading states while mobile step 2 categories and products resolve", async ({ page }) => {
|
||||
const orderId = 9401;
|
||||
const fixture = createMobilePosFixture({
|
||||
departmentCategoriesDelayMs: 500,
|
||||
ordersById: {
|
||||
[orderId]: buildRegularOrder(orderId, {
|
||||
reference: "STEP2-LOADING-REF",
|
||||
reg_1: "ZZ00000",
|
||||
}),
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
[orderId]: [],
|
||||
},
|
||||
departmentCategoriesDelayMs: 3_000,
|
||||
productsDelayMsByCategory: {
|
||||
4: 700,
|
||||
8: 700,
|
||||
4: 3_000,
|
||||
8: 3_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3003,6 +3023,7 @@ test.describe("POS mobile order flow", () => {
|
||||
},
|
||||
route: {
|
||||
step: 2,
|
||||
orderId,
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
},
|
||||
});
|
||||
@@ -3015,6 +3036,7 @@ test.describe("POS mobile order flow", () => {
|
||||
await expect(productLoading).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-category-4")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-category-4").click();
|
||||
await expect(categoryLoading).toBeHidden({ timeout: 10_000 });
|
||||
await expect(productLoading).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -3022,7 +3044,6 @@ test.describe("POS mobile order flow", () => {
|
||||
await expect(productLoading).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("pos-mobile-category-8").click();
|
||||
await expect(productLoading).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-product-91")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(productLoading).toBeHidden({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
@@ -11,14 +11,15 @@ const POS_PERMISSIONS = [
|
||||
"list_customer_attributes",
|
||||
"get_custom_prices_other",
|
||||
];
|
||||
const POS_STEP_TIMEOUT = 20_000;
|
||||
|
||||
async function primeSession(page, token = "pos-visual-token") {
|
||||
await seedAuthenticatedState(page, token);
|
||||
const sessionRequest = page.waitForResponse((response) => {
|
||||
return response.request().method() === "GET" && response.url().includes("/auth/session");
|
||||
await page.goto("/login", { waitUntil: "domcontentloaded" });
|
||||
await page.evaluate(async () => {
|
||||
const { SessionUser } = await import("/src/components/session/token/SessionUser.vue");
|
||||
await SessionUser.getSessionData();
|
||||
});
|
||||
await page.goto("/login");
|
||||
await sessionRequest;
|
||||
}
|
||||
|
||||
async function seedMobileStepTwoState(page, { customerId = 12345679, orderId = 54518 } = {}) {
|
||||
@@ -229,12 +230,35 @@ async function waitForOrderDetailMetadata(page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectClippedLocatorScreenshot(page, locator, snapshotName, { width, height, maxDiffPixels = 300 }) {
|
||||
async function expectClippedLocatorScreenshot(
|
||||
page,
|
||||
locator,
|
||||
snapshotName,
|
||||
{ width, height, maxDiffPixels = 300, resetScroll = false, expandViewportForClip = false }
|
||||
) {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
const box = await locator.boundingBox();
|
||||
if (resetScroll) {
|
||||
await resetScrollableAncestor(locator);
|
||||
}
|
||||
let box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Unable to capture screenshot for ${snapshotName}: locator has no bounding box.`);
|
||||
}
|
||||
const viewportSize = page.viewportSize();
|
||||
if (expandViewportForClip && viewportSize && box.y + height > viewportSize.height) {
|
||||
await page.setViewportSize({
|
||||
width: viewportSize.width,
|
||||
height: Math.ceil(box.y + height),
|
||||
});
|
||||
if (resetScroll) {
|
||||
await resetScrollableAncestor(locator);
|
||||
}
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve)));
|
||||
box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Unable to capture screenshot for ${snapshotName}: locator has no bounding box.`);
|
||||
}
|
||||
}
|
||||
const screenshot = await page.screenshot({
|
||||
clip: {
|
||||
x: Math.floor(box.x),
|
||||
@@ -247,6 +271,16 @@ async function expectClippedLocatorScreenshot(page, locator, snapshotName, { wid
|
||||
expect(screenshot).toMatchSnapshot(snapshotName, { maxDiffPixels });
|
||||
}
|
||||
|
||||
async function resetScrollableAncestor(locator) {
|
||||
await locator.evaluate((element) => {
|
||||
const scrollParent = element.closest(".independent-scroll");
|
||||
if (scrollParent) {
|
||||
scrollParent.scrollTop = 0;
|
||||
scrollParent.scrollLeft = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getVisibleTestId(page, testId) {
|
||||
return page.locator(`[data-testid="${testId}"]:visible`).first();
|
||||
}
|
||||
@@ -293,8 +327,11 @@ test.describe("POS visuals", () => {
|
||||
orderBookings: [
|
||||
{
|
||||
id: 77,
|
||||
department: 12,
|
||||
reg_1: "AB12345",
|
||||
reg_2: "",
|
||||
order_id: null,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -309,7 +346,7 @@ test.describe("POS visuals", () => {
|
||||
|
||||
await page.goto("/admin/12/modules/pos");
|
||||
const stepOne = page.getByTestId("pos-step-1");
|
||||
await expect(stepOne).toBeVisible();
|
||||
await expect(stepOne).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await expectPosCardTabsDividerSpacing(stepOne);
|
||||
await expect(page.getByTestId("pos-recent-scan-row-801")).toBeVisible();
|
||||
await expect(stepOne.getByText(/^Booket$/)).toBeVisible();
|
||||
@@ -326,8 +363,11 @@ test.describe("POS visuals", () => {
|
||||
orderBookings: [
|
||||
{
|
||||
id: 77,
|
||||
department: 12,
|
||||
reg_1: "AB12345",
|
||||
reg_2: "",
|
||||
order_id: null,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -341,7 +381,7 @@ test.describe("POS visuals", () => {
|
||||
await primeSession(page, "pos-visual-desktop-step-1-expanded-token");
|
||||
|
||||
await page.goto("/admin/12/modules/pos");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await expect(page.getByTestId("pos-recent-scan-details-801")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("pos-recent-scan-row-801").click();
|
||||
@@ -368,18 +408,24 @@ test.describe("POS visuals", () => {
|
||||
await primeSession(page, "pos-visual-desktop-step-1-customer-token");
|
||||
|
||||
await page.goto("/admin/12/modules/pos");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
const stepOne = page.getByTestId("pos-step-1");
|
||||
await expect(stepOne).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await page.locator("#reg_1").fill("EC21235");
|
||||
await expect(page.getByTestId("pos-step-1").locator(".pos-card-tabs > .tabs li.is-active")).toContainText("Kunde");
|
||||
await expectPosCardTabsDividerSpacing(page.getByTestId("pos-step-1"));
|
||||
await expectCustomerTabsDividerSpacing(page.getByTestId("pos-step-1"));
|
||||
await expect(stepOne.locator(".pos-card-tabs > .tabs li.is-active")).toContainText("Kunde");
|
||||
await expectPosCardTabsDividerSpacing(stepOne);
|
||||
await expectCustomerTabsDividerSpacing(stepOne);
|
||||
await expect(page.getByTestId("pos-desktop-vehicle-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-desktop-last-wash")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-desktop-last-wash")).toContainText("Indvendig vask Forvogn");
|
||||
await expect(page.getByTestId("pos-desktop-secondary-block")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-desktop-last-wash-copy")).toBeDisabled();
|
||||
await expect(page.getByTestId("pos-step-1")).toHaveScreenshot("pos-step-1-customer-desktop.png", {
|
||||
const normalizeFirefoxClip = testInfo.project.name === "firefox-desktop";
|
||||
await expectClippedLocatorScreenshot(page, stepOne, "pos-step-1-customer-desktop.png", {
|
||||
width: 860,
|
||||
height: 663,
|
||||
maxDiffPixels: 2000,
|
||||
resetScroll: normalizeFirefoxClip,
|
||||
expandViewportForClip: normalizeFirefoxClip,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -416,7 +462,7 @@ test.describe("POS visuals", () => {
|
||||
await primeSession(page, "pos-visual-desktop-step-1-duplicate-warning-token");
|
||||
|
||||
await page.goto("/admin/12/modules/pos");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
|
||||
await commitDesktopReg1ByBlur(page, "AB12345");
|
||||
|
||||
@@ -461,7 +507,7 @@ test.describe("POS visuals", () => {
|
||||
await primeSession(page, "pos-visual-desktop-step-1-notes-token");
|
||||
|
||||
await page.goto("/admin/12/modules/pos");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
|
||||
const stepOne = page.getByTestId("pos-step-1");
|
||||
const registrationInput = page.locator("#reg_1");
|
||||
@@ -492,14 +538,19 @@ test.describe("POS visuals", () => {
|
||||
await primeSession(page, "pos-visual-desktop-token");
|
||||
|
||||
await page.goto("/admin/12/modules/pos/orders/54518");
|
||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||
const orderDetail = page.getByTestId("pos-order-detail");
|
||||
await expect(orderDetail).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await expect(page.locator('[data-auto-excel-export-button="1"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId("pos-order-registration-add-2")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-note-empty-state")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-item-edit-9101")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-add-item")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-total")).toContainText("1372 DKK");
|
||||
await expect(page.getByTestId("pos-order-detail")).toHaveScreenshot("pos-order-detail-desktop.png", {
|
||||
await expect(orderDetail.locator(".pos-selected-customer--order-detail")).toContainText(
|
||||
"(TEST) Pleno Vognmandsforretning"
|
||||
);
|
||||
await expect(orderDetail.getByRole("button", { name: /Kvittering/i })).toBeVisible();
|
||||
await expect(orderDetail).toHaveScreenshot("pos-order-detail-desktop.png", {
|
||||
maxDiffPixels: 300,
|
||||
});
|
||||
});
|
||||
@@ -516,7 +567,8 @@ test.describe("POS visuals", () => {
|
||||
await primeSession(page, "pos-visual-desktop-required-warnings-token");
|
||||
|
||||
await page.goto("/admin/12/modules/pos/orders/54518");
|
||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||
const orderDetail = page.getByTestId("pos-order-detail");
|
||||
await expect(orderDetail).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await expect(page.getByTestId("pos-order-customer-wishes-reference-control")).toHaveAttribute(
|
||||
"data-warning-state",
|
||||
"danger"
|
||||
@@ -525,12 +577,13 @@ test.describe("POS visuals", () => {
|
||||
"data-warning-state",
|
||||
"warning"
|
||||
);
|
||||
await expect(page.getByTestId("pos-order-detail")).toHaveScreenshot(
|
||||
"pos-order-detail-desktop-required-warnings.png",
|
||||
{
|
||||
maxDiffPixels: 300,
|
||||
}
|
||||
await expect(orderDetail.locator(".pos-selected-customer--order-detail")).toContainText(
|
||||
"(TEST) Pleno Vognmandsforretning"
|
||||
);
|
||||
await expect(orderDetail.getByRole("button", { name: /Kvittering/i })).toBeVisible();
|
||||
await expect(orderDetail).toHaveScreenshot("pos-order-detail-desktop-required-warnings.png", {
|
||||
maxDiffPixels: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test("desktop step 2 shared workspace snapshot", async ({ page }, testInfo) => {
|
||||
@@ -545,7 +598,7 @@ test.describe("POS visuals", () => {
|
||||
await primeSession(page, "pos-visual-desktop-step-2-title-token");
|
||||
|
||||
await page.goto("/admin/12/modules/pos");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
await page.locator("#reg_1").fill("EC21235");
|
||||
await expect(page.getByTestId("pos-step-1").locator(".pos-card-tabs > .tabs li.is-active")).toContainText("Kunde");
|
||||
|
||||
@@ -561,6 +614,7 @@ test.describe("POS visuals", () => {
|
||||
await expect(primaryAction).toBeVisible();
|
||||
await expect(clearAllAction).toBeVisible();
|
||||
await expectPrimaryActionAboveClearAll(primaryAction, clearAllAction);
|
||||
await resetScrollableAncestor(stepTwo);
|
||||
await expect(stepTwo).toHaveScreenshot("pos-step-2-desktop.png", {
|
||||
maxDiffPixels: 300,
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi } from "./support/network.js";
|
||||
|
||||
const controlSelectors = ["#customer_cvr", "#email", "#customer_phone", "#customer-register-button"];
|
||||
|
||||
async function expectNoHorizontalOverflow(page) {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
|
||||
expect(Math.max(dimensions.bodyScrollWidth, dimensions.documentScrollWidth)).toBeLessThanOrEqual(
|
||||
dimensions.viewportWidth + 1
|
||||
);
|
||||
}
|
||||
|
||||
async function expectControlWithinViewport(page, selector: string) {
|
||||
const box = await page.locator(selector).boundingBox();
|
||||
expect(box, `${selector} should have a rendered box`).not.toBeNull();
|
||||
const viewport = page.viewportSize();
|
||||
expect(viewport, "Project should define a viewport").not.toBeNull();
|
||||
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1);
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
test.describe("/qr/new-customer responsive layout", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockApi(page, { authenticated: false });
|
||||
});
|
||||
|
||||
test("renders a mobile-safe customer registration layout", async ({ page }) => {
|
||||
await page.goto("/qr/new-customer");
|
||||
|
||||
await expect(page.locator("#qr-new-customer-title")).toBeVisible();
|
||||
await expect(page.locator(".hero-card")).toBeVisible();
|
||||
await expect(page.locator(".form-card")).toBeVisible();
|
||||
|
||||
for (const selector of controlSelectors) {
|
||||
await expect(page.locator(selector)).toBeVisible();
|
||||
await expectControlWithinViewport(page, selector);
|
||||
}
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
});
|
||||
@@ -1497,10 +1497,11 @@ function applyVirtualHardwareToGraph(graph) {
|
||||
}
|
||||
|
||||
async function installStudioRoutes(page, graph, captured) {
|
||||
const dynamicImagePng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
|
||||
"base64"
|
||||
);
|
||||
const dynamicImageSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" fill="#2563eb"/>
|
||||
</svg>
|
||||
`;
|
||||
const customerResults = [
|
||||
{ id: 601001, customerNumber: 601001, name: "Acme Logistics", city: "Roskilde" },
|
||||
{ id: 602002, customerNumber: 602002, name: "Nordic Wash Transport", city: "Koge" },
|
||||
@@ -1786,8 +1787,8 @@ async function installStudioRoutes(page, graph, captured) {
|
||||
captured.dynamicImages.push(new URL(request.url()));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "image/png",
|
||||
body: dynamicImagePng,
|
||||
contentType: "image/svg+xml",
|
||||
body: dynamicImageSvg,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1821,7 +1822,7 @@ test.describe("All-in-one self-serve studio", () => {
|
||||
await primeMockSession(page, { token: "self-serve-studio-token" });
|
||||
});
|
||||
|
||||
test("loads the replacement graph workspace and edits a question from the inspector", async ({ page }) => {
|
||||
test("loads the replacement graph workspace and edits a question from the inspector", async ({ page }, testInfo) => {
|
||||
const graph = buildStudioGraph();
|
||||
const captured = {
|
||||
graphSaves: [],
|
||||
@@ -1836,6 +1837,16 @@ test.describe("All-in-one self-serve studio", () => {
|
||||
published: 0,
|
||||
};
|
||||
await installStudioRoutes(page, graph, captured);
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(Element.prototype, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
Object.defineProperty(Document.prototype, "exitFullscreen", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/admin/6/modules/self-serve/studio", { waitUntil: "domcontentloaded", timeout: 90_000 });
|
||||
|
||||
@@ -1986,8 +1997,11 @@ test.describe("All-in-one self-serve studio", () => {
|
||||
};
|
||||
});
|
||||
expect(focusedNodeMetrics.count).toBeGreaterThanOrEqual(4);
|
||||
expect(focusedNodeMetrics.spreadX).toBeLessThan(980);
|
||||
expect(focusedNodeMetrics.spreadY).toBeLessThan(460);
|
||||
const isWebKitProject = testInfo.project.name.startsWith("webkit-");
|
||||
const maxFocusedSpreadX = isWebKitProject ? 1120 : 980;
|
||||
const maxFocusedSpreadY = isWebKitProject ? 560 : 460;
|
||||
expect(focusedNodeMetrics.spreadX).toBeLessThan(maxFocusedSpreadX);
|
||||
expect(focusedNodeMetrics.spreadY).toBeLessThan(maxFocusedSpreadY);
|
||||
await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" });
|
||||
await expect
|
||||
.poll(async () => page.locator('[data-testid="studio-custom-node-scope"]:visible h4').allTextContents())
|
||||
|
||||
@@ -94,6 +94,12 @@ test.describe("Superuser bookings", () => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
const orderBookingFilters: string[] = [];
|
||||
const now = new Date();
|
||||
const todaySqlDate = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
|
||||
await seedAuthenticatedState(page);
|
||||
await mockApi(page, {
|
||||
@@ -173,7 +179,7 @@ test.describe("Superuser bookings", () => {
|
||||
customer_name: "Nord Transport",
|
||||
reg_1: "AB12345",
|
||||
reg_2: null,
|
||||
datetime: "2026-04-21 09:00:00",
|
||||
datetime: `${todaySqlDate} 09:00:00`,
|
||||
po: "",
|
||||
reference: "REF-1",
|
||||
pickup: false,
|
||||
@@ -186,7 +192,7 @@ test.describe("Superuser bookings", () => {
|
||||
customer_name: "Vest Logistik",
|
||||
reg_1: "CD67890",
|
||||
reg_2: null,
|
||||
datetime: "2026-04-21 11:00:00",
|
||||
datetime: `${todaySqlDate} 11:00:00`,
|
||||
po: "",
|
||||
reference: "REF-2",
|
||||
pickup: true,
|
||||
@@ -199,7 +205,7 @@ test.describe("Superuser bookings", () => {
|
||||
customer_name: "Syd Cargo",
|
||||
reg_1: "EF24680",
|
||||
reg_2: null,
|
||||
datetime: "2026-04-21 13:00:00",
|
||||
datetime: `${todaySqlDate} 13:00:00`,
|
||||
po: "",
|
||||
reference: "REF-3",
|
||||
pickup: false,
|
||||
@@ -221,6 +227,9 @@ test.describe("Superuser bookings", () => {
|
||||
await page.goto("/superuser/orders");
|
||||
|
||||
const desktopNavigation = page.getByTestId("desktop-buefy-navigation");
|
||||
await expect(desktopNavigation).toContainText("Transaktionshistorik");
|
||||
await expect(desktopNavigation).toContainText("Bookinger");
|
||||
await expect(desktopNavigation).toContainText("Kladder");
|
||||
const navigationText = await desktopNavigation.innerText();
|
||||
expect(navigationText.indexOf("Transaktionshistorik")).toBeLessThan(navigationText.indexOf("Bookinger"));
|
||||
expect(navigationText.indexOf("Bookinger")).toBeLessThan(navigationText.indexOf("Kladder"));
|
||||
@@ -244,6 +253,12 @@ test.describe("Superuser bookings", () => {
|
||||
await expect(page.locator("body")).toContainText("AB12345");
|
||||
await expect(page.locator("body")).toContainText("CD67890");
|
||||
|
||||
await page.locator(".action-settings-wheel-trigger").first().click();
|
||||
const bookingMenu = page.locator(".dropdown-content", { hasText: "Tilknyt ordre" }).first();
|
||||
await expect(bookingMenu).toBeVisible();
|
||||
await expect(bookingMenu).not.toContainText("Marker som fuldf\u00f8rt");
|
||||
await expect(bookingMenu).not.toContainText("Complete booking");
|
||||
|
||||
expect(orderBookingFilters.length).toBeGreaterThan(0);
|
||||
expect(orderBookingFilters.every((filter) => filter === "counts")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -28,17 +28,20 @@ test("[BOOKINGS][User][Creation] should create a new booking", async ({ page })
|
||||
|
||||
test("[BOOKINGS][User][Creation] should show interior wash products on the interior category", async ({ page }) => {
|
||||
await prepareBookingPage(page);
|
||||
await goToBookingProductSelectionStep(page, bookingTestData);
|
||||
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, {
|
||||
selectInitialProduct: false,
|
||||
});
|
||||
|
||||
const interiorCategoryTab = page.getByTestId("pos-product-category-tab-2");
|
||||
if (await interiorCategoryTab.isVisible().catch(() => false)) {
|
||||
const interiorCategoryTab = page.locator('[data-testid="pos-product-category-tab-2"]:visible').first();
|
||||
if ((await interiorCategoryTab.count()) > 0) {
|
||||
await interiorCategoryTab.click();
|
||||
} else {
|
||||
await page.getByTestId("pos-product-category-select").selectOption("2");
|
||||
await page.locator('[data-testid="pos-product-category-select"]:visible').first().selectOption("2");
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("pos-product-card-63")).toBeVisible();
|
||||
await expect(page.getByText("Indvendig vask Forvogn")).toBeVisible();
|
||||
const interiorProductCard = page.locator('[data-testid="pos-product-card-63"]:visible').first();
|
||||
await expect(interiorProductCard).toBeVisible();
|
||||
await expect(interiorProductCard).toContainText("Indvendig vask Forvogn");
|
||||
});
|
||||
|
||||
test("[BOOKINGS][User][Creation] should show desktop loading states while categories and products resolve", async ({
|
||||
@@ -55,15 +58,17 @@ test("[BOOKINGS][User][Creation] should show desktop loading states while catego
|
||||
selectInitialProduct: false,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-product-categories-loading")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-products-loading")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="pos-product-categories-loading"]:visible').first()).toBeVisible();
|
||||
await expect(page.locator('[data-testid="pos-products-loading"]:visible').first()).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("pos-product-categories-loading")).toHaveCount(0, { timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-product-card-53")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-products-loading")).toHaveCount(0, { timeout: 10_000 });
|
||||
await expect(page.locator('[data-testid="pos-product-categories-loading"]:visible')).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.locator('[data-testid="pos-product-card-53"]:visible').first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('[data-testid="pos-products-loading"]:visible')).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("pos-product-category-tab-2").click();
|
||||
await expect(page.getByTestId("pos-products-loading")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-product-card-63")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-products-loading")).toHaveCount(0, { timeout: 10_000 });
|
||||
await page.locator('[data-testid="pos-product-category-tab-2"]:visible').first().click();
|
||||
await expect(page.locator('[data-testid="pos-products-loading"]:visible').first()).toBeVisible();
|
||||
await expect(page.locator('[data-testid="pos-product-card-63"]:visible').first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('[data-testid="pos-products-loading"]:visible')).toHaveCount(0, { timeout: 10_000 });
|
||||
});
|
||||
|
||||
@@ -74,7 +74,6 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
},
|
||||
showEditObjectFieldForm: vi.fn(() => Promise.resolve()),
|
||||
functions: {
|
||||
showCompleteConfirmationModal: vi.fn(() => Promise.resolve()),
|
||||
showDeleteConfirmationModal: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
},
|
||||
@@ -618,6 +617,23 @@ describe("ActionSettingsWheelButton", () => {
|
||||
geometrySpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not render standalone booking completion for unlinked bookings", async () => {
|
||||
const wrapper = mountFlatDropdownButton({
|
||||
order_id: null,
|
||||
order_booking_id: 123,
|
||||
department_id: 1,
|
||||
customer_number: 123456,
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
await wrapper.find(".dropdown-trigger button").trigger("click");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(wrapper.text()).toContain("admin.pos.settings_wheel.booking");
|
||||
expect(wrapper.text()).toContain("admin.pos.settings_wheel.view_booking_new_tab");
|
||||
expect(wrapper.text()).not.toContain("admin.pos.settings_wheel.mark_as_completed");
|
||||
});
|
||||
|
||||
it("routes 'change invoice collection' action without opening a new tab", async () => {
|
||||
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
|
||||
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
|
||||
|
||||
const formatLocalDate = (date) => {
|
||||
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}`;
|
||||
};
|
||||
|
||||
describe("DatePeriodSelector mobile layout", () => {
|
||||
beforeEach(() => {
|
||||
sharedState.width.value = 480;
|
||||
@@ -109,10 +116,52 @@ describe("DatePeriodSelector mobile layout", () => {
|
||||
await wrapper.get("[data-testid='date-period-end']").setValue("2026-03-05");
|
||||
|
||||
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
|
||||
expect(emittedSelection.startDate.toISOString().split("T")[0]).toBe("2026-03-01");
|
||||
expect(emittedSelection.endDate.toISOString().split("T")[0]).toBe("2026-03-05");
|
||||
expect(formatLocalDate(emittedSelection.startDate)).toBe("2026-03-01");
|
||||
expect(formatLocalDate(emittedSelection.endDate)).toBe("2026-03-05");
|
||||
expect(onSelectionChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSelectionChange.mock.calls[0][0].toISOString().split("T")[0]).toBe("2026-03-01");
|
||||
expect(onSelectionChange.mock.calls[0][1].toISOString().split("T")[0]).toBe("2026-03-05");
|
||||
expect(formatLocalDate(onSelectionChange.mock.calls[0][0])).toBe("2026-03-01");
|
||||
expect(formatLocalDate(onSelectionChange.mock.calls[0][1])).toBe("2026-03-05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DatePeriodSelector month warning", () => {
|
||||
beforeEach(() => {
|
||||
sharedState.width.value = 1024;
|
||||
});
|
||||
|
||||
it("sets the current month from its first day through its last day", async () => {
|
||||
const onSelectionChange = vi.fn();
|
||||
const wrapper = mount(DatePeriodSelector, {
|
||||
props: {
|
||||
selection: {
|
||||
startDate: new Date(2026, 4, 4, 12, 30, 0, 0),
|
||||
endDate: new Date(2026, 4, 5, 15, 45, 0, 0),
|
||||
},
|
||||
onSelectionChange,
|
||||
visibility: {
|
||||
showUpdateButton: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.get("[data-testid='date-period-set-entire-month']").trigger("click");
|
||||
|
||||
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
|
||||
expect(emittedSelection).toBeTruthy();
|
||||
expect(emittedSelection.startDate.getFullYear()).toBe(2026);
|
||||
expect(emittedSelection.startDate.getMonth()).toBe(4);
|
||||
expect(emittedSelection.startDate.getDate()).toBe(1);
|
||||
expect(emittedSelection.startDate.getHours()).toBe(0);
|
||||
expect(emittedSelection.startDate.getMinutes()).toBe(0);
|
||||
expect(emittedSelection.startDate.getSeconds()).toBe(0);
|
||||
expect(emittedSelection.startDate.getMilliseconds()).toBe(0);
|
||||
expect(emittedSelection.endDate.getFullYear()).toBe(2026);
|
||||
expect(emittedSelection.endDate.getMonth()).toBe(4);
|
||||
expect(emittedSelection.endDate.getDate()).toBe(31);
|
||||
expect(emittedSelection.endDate.getHours()).toBe(23);
|
||||
expect(emittedSelection.endDate.getMinutes()).toBe(59);
|
||||
expect(emittedSelection.endDate.getSeconds()).toBe(59);
|
||||
expect(emittedSelection.endDate.getMilliseconds()).toBe(999);
|
||||
expect(onSelectionChange).toHaveBeenCalledWith(emittedSelection.startDate, emittedSelection.endDate);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getOrderItems: vi.fn(),
|
||||
createOrderItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
@@ -43,6 +44,7 @@ vi.mock("@/components/shop/CustomerNotes.vue", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shop/OrdersItems.vue", () => ({
|
||||
createOrderItem: mocks.createOrderItem,
|
||||
getOrderItems: mocks.getOrderItems,
|
||||
}));
|
||||
|
||||
@@ -75,10 +77,14 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
removeAttachment: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
downloadAttachment: vi.fn(),
|
||||
get_department_id: vi.fn(),
|
||||
},
|
||||
get: {
|
||||
single: vi.fn(),
|
||||
},
|
||||
set: {
|
||||
department_id: vi.fn(),
|
||||
},
|
||||
},
|
||||
order_bookings: {
|
||||
meta: {
|
||||
@@ -106,9 +112,12 @@ vi.mock("sweetalert2", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import axios from "axios";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
clearStoredPosOrderId,
|
||||
createOrder,
|
||||
department_id,
|
||||
loadOrderItems,
|
||||
order_id,
|
||||
order_items,
|
||||
@@ -169,7 +178,10 @@ describe("POSDepartmentProcess.restoreStoredPosOrderId", () => {
|
||||
beforeEach(() => {
|
||||
clearStoredPosOrderId();
|
||||
order_id.value = null;
|
||||
department_id.value = "";
|
||||
SessionUser.objects.orders.get.single.mockReset();
|
||||
SessionUser.objects.orders.functions.get_department_id.mockReset();
|
||||
SessionUser.objects.orders.set.department_id.mockReset();
|
||||
});
|
||||
|
||||
it("clears invalid stored order ids before validation", async () => {
|
||||
@@ -239,4 +251,79 @@ describe("POSDepartmentProcess.restoreStoredPosOrderId", () => {
|
||||
expect(order_id.value).toBeNull();
|
||||
expect(localStorage.getItem("pos_order_id")).toBeNull();
|
||||
});
|
||||
|
||||
it("moves a stored current order to the selected department when requested", async () => {
|
||||
localStorage.setItem("pos_order_id", "51211");
|
||||
department_id.value = 2;
|
||||
SessionUser.objects.orders.get.single.mockResolvedValue({
|
||||
id: 51211,
|
||||
customer_id: 12345679,
|
||||
department_id: 88,
|
||||
completed_at: null,
|
||||
});
|
||||
SessionUser.objects.orders.functions.get_department_id.mockResolvedValue(88);
|
||||
SessionUser.objects.orders.set.department_id.mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await expect(
|
||||
restoreStoredPosOrderId({
|
||||
validateOrder: true,
|
||||
customerId: 12345679,
|
||||
departmentId: 2,
|
||||
syncDepartment: true,
|
||||
})
|
||||
).resolves.toBe(51211);
|
||||
|
||||
expect(SessionUser.objects.orders.set.department_id).toHaveBeenCalledWith(51211, 2);
|
||||
expect(order_id.value).toBe(51211);
|
||||
expect(department_id.value).toBe(2);
|
||||
expect(localStorage.getItem("pos_order_id")).toBe("51211");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POSDepartmentProcess.createOrder department sync", () => {
|
||||
beforeEach(() => {
|
||||
order_id.value = null;
|
||||
department_id.value = "";
|
||||
axios.post.mockReset();
|
||||
SessionUser.objects.orders.functions.get_department_id.mockReset();
|
||||
SessionUser.objects.orders.set.department_id.mockReset();
|
||||
localStorage.setItem("token", "test-token");
|
||||
});
|
||||
|
||||
it("updates an existing current order to the selected department instead of creating a new order", async () => {
|
||||
order_id.value = 9201;
|
||||
department_id.value = 7;
|
||||
SessionUser.objects.orders.functions.get_department_id.mockResolvedValue(3);
|
||||
SessionUser.objects.orders.set.department_id.mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await expect(createOrder({ isMobile: true })).resolves.toBe(true);
|
||||
|
||||
expect(SessionUser.objects.orders.set.department_id).toHaveBeenCalledWith(9201, 7);
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a new order in the selected department", async () => {
|
||||
department_id.value = 7;
|
||||
axios.post.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
id: 9202,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(createOrder({ isMobile: true })).resolves.toBe(true);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
department_id: 7,
|
||||
is_handheld: true,
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(order_id.value).toBe(9202);
|
||||
expect(department_id.value).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
@@ -22,6 +23,9 @@ const BUEFY_CSS_REPLACEMENTS = new Map([
|
||||
]
|
||||
])
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
const fromProjectRoot = (...segments) => path.join(projectRoot, ...segments)
|
||||
|
||||
function getGitCommit() {
|
||||
try {
|
||||
return execSync('git rev-parse --short HEAD').toString().trim()
|
||||
@@ -195,9 +199,12 @@ export default defineConfig(({ mode }) => {
|
||||
].filter(Boolean),
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
'@': fromProjectRoot('src'),
|
||||
'vue-router': fromProjectRoot('node_modules', 'vue-router', 'dist', 'vue-router.mjs'),
|
||||
'vue-i18n': fromProjectRoot('node_modules', 'vue-i18n', 'dist', 'vue-i18n.mjs')
|
||||
},
|
||||
preserveSymlinks: true
|
||||
preserveSymlinks: true,
|
||||
dedupe: ['vue', 'vue-router', 'vue-i18n', '@vueuse/core', '@vueuse/head', '@unhead/vue']
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString()),
|
||||
@@ -218,14 +225,16 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/output/playwright/**']
|
||||
ignored: ['**/output/playwright/**', '**/node_modules.codex-backup/**']
|
||||
}
|
||||
},
|
||||
optimizeDeps: isPlaywrightRuntime
|
||||
? {
|
||||
noDiscovery: true
|
||||
}
|
||||
: undefined
|
||||
: {
|
||||
entries: ['index.html', 'src/**/*.{vue,js,ts,jsx,tsx}']
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
|
||||