Add Stripe invoice actions and order booking counts:
- Introduced support for managing Stripe invoices in `DepartmentPosOrder.vue`, including helper functions for status handling, action visibility, and cancellation. - Added `isStripeHostedInvoiceOpen` and `shouldShowStripeInvoiceAction` checks to control panel visibility. - Enhanced `fetchSuperUserBookingCounts` with a new endpoint for department-scoped booking totals and streamlined logic for grouped counts. - Updated e2e tests to reflect booking badge tooltips and streamlined department-based count requests.
This commit is contained in:
@@ -9,6 +9,8 @@ export type badge = {
|
||||
condition?: boolean; // Optional condition to show/hide the badge
|
||||
classes?: string[]; // Optional CSS classes for custom badge styling
|
||||
testId?: string; // Optional test id for stable selectors
|
||||
tooltip?: string; // Optional tooltip label shown when hovering the badge
|
||||
tooltipTestId?: string; // Optional test id for the tooltip content
|
||||
};
|
||||
|
||||
export type NavigationItemTypeOf = 'department' | 'user' | 'category' | 'link' | 'button';
|
||||
|
||||
@@ -79,6 +79,8 @@ const getBookingsLoadingBadge = () => ({
|
||||
condition: true,
|
||||
classes: ['desktop-buefy-badge-loading'],
|
||||
testId: 'desktop-buefy-nav-bookings-badge-loading',
|
||||
tooltip: 'Indlæser bookinger',
|
||||
tooltipTestId: 'desktop-buefy-nav-bookings-badge-loading-tooltip',
|
||||
});
|
||||
|
||||
const getDepartmentBookingBadges = () => {
|
||||
@@ -92,12 +94,16 @@ const getDepartmentBookingBadges = () => {
|
||||
text: department_booking_counts.value.past.toString(),
|
||||
condition: department_booking_counts.value.past > 0,
|
||||
testId: 'desktop-buefy-nav-bookings-badge-overdue',
|
||||
tooltip: 'Tidligere',
|
||||
tooltipTestId: 'desktop-buefy-nav-bookings-badge-overdue-tooltip',
|
||||
},
|
||||
{
|
||||
type: 'info' as const,
|
||||
text: department_booking_counts.value.current.toString(),
|
||||
condition: department_booking_counts.value.current > 0,
|
||||
testId: 'desktop-buefy-nav-bookings-badge',
|
||||
tooltip: 'I dag',
|
||||
tooltipTestId: 'desktop-buefy-nav-bookings-badge-tooltip',
|
||||
},
|
||||
{
|
||||
type: 'info' as const,
|
||||
@@ -105,6 +111,8 @@ const getDepartmentBookingBadges = () => {
|
||||
condition: department_booking_counts.value.future > 0,
|
||||
classes: ['desktop-buefy-bookings-badge-future'],
|
||||
testId: 'desktop-buefy-nav-bookings-badge-future',
|
||||
tooltip: 'Fremtidige',
|
||||
tooltipTestId: 'desktop-buefy-nav-bookings-badge-future-tooltip',
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -115,6 +123,8 @@ const getDraftsLoadingBadge = () => ({
|
||||
condition: true,
|
||||
classes: ['desktop-buefy-badge-loading'],
|
||||
testId: 'desktop-buefy-nav-drafts-badge-loading',
|
||||
tooltip: 'Indlæser kladder',
|
||||
tooltipTestId: 'desktop-buefy-nav-drafts-badge-loading-tooltip',
|
||||
});
|
||||
|
||||
const getDepartmentDraftBadge = () => {
|
||||
@@ -127,6 +137,8 @@ const getDepartmentDraftBadge = () => {
|
||||
text: department_draft_count.value.toString(),
|
||||
condition: department_draft_count.value > 0,
|
||||
testId: 'desktop-buefy-nav-drafts-badge',
|
||||
tooltip: t('nav.drafts'),
|
||||
tooltipTestId: 'desktop-buefy-nav-drafts-badge-tooltip',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ const getSuperUserBookingsBadges = () => {
|
||||
condition: true,
|
||||
classes: ['desktop-buefy-badge-loading'],
|
||||
testId: 'desktop-buefy-nav-bookings-badge-loading',
|
||||
tooltip: 'Indlæser bookinger',
|
||||
tooltipTestId: 'desktop-buefy-nav-bookings-badge-loading-tooltip',
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -49,12 +51,16 @@ const getSuperUserBookingsBadges = () => {
|
||||
text: superuser_booking_counts.value.past.toString(),
|
||||
condition: superuser_booking_counts.value.past > 0,
|
||||
testId: 'desktop-buefy-nav-bookings-badge-overdue',
|
||||
tooltip: 'Tidligere',
|
||||
tooltipTestId: 'desktop-buefy-nav-bookings-badge-overdue-tooltip',
|
||||
},
|
||||
{
|
||||
type: 'info' as const,
|
||||
text: superuser_booking_counts.value.current.toString(),
|
||||
condition: superuser_booking_counts.value.current > 0,
|
||||
testId: 'desktop-buefy-nav-bookings-badge',
|
||||
tooltip: 'I dag',
|
||||
tooltipTestId: 'desktop-buefy-nav-bookings-badge-tooltip',
|
||||
},
|
||||
{
|
||||
type: 'info' as const,
|
||||
@@ -62,6 +68,8 @@ const getSuperUserBookingsBadges = () => {
|
||||
condition: superuser_booking_counts.value.future > 0,
|
||||
classes: ['desktop-buefy-bookings-badge-future'],
|
||||
testId: 'desktop-buefy-nav-bookings-badge-future',
|
||||
tooltip: 'Fremtidige',
|
||||
tooltipTestId: 'desktop-buefy-nav-bookings-badge-future-tooltip',
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -74,6 +82,8 @@ const getSuperUserDraftsBadge = () => {
|
||||
condition: true,
|
||||
classes: ['desktop-buefy-badge-loading'],
|
||||
testId: 'desktop-buefy-nav-drafts-badge-loading',
|
||||
tooltip: 'Indlæser kladder',
|
||||
tooltipTestId: 'desktop-buefy-nav-drafts-badge-loading-tooltip',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,6 +92,8 @@ const getSuperUserDraftsBadge = () => {
|
||||
text: superuser_draft_count.value.toString(),
|
||||
condition: superuser_draft_count.value > 0,
|
||||
testId: 'desktop-buefy-nav-drafts-badge',
|
||||
tooltip: t('nav.drafts'),
|
||||
tooltipTestId: 'desktop-buefy-nav-drafts-badge-tooltip',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,106 +1,25 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
const BOOKING_COUNT_PAGE_SIZE = 100;
|
||||
const MAX_BOOKING_COUNT_PAGES = 100;
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const toBookingDate = (value) => {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedValue = value.includes("T") ? value : value.replace(" ", "T");
|
||||
const parsedDate = new Date(normalizedValue);
|
||||
|
||||
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
|
||||
const toNonNegativeInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : 0;
|
||||
};
|
||||
|
||||
const buildDayBoundaries = () => {
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date();
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
const normalizeBookingCounts = (response) => {
|
||||
const counts = response?.data?.data;
|
||||
|
||||
return {
|
||||
todayStartDate: todayStart,
|
||||
todayEndDate: todayEnd,
|
||||
past: toNonNegativeInteger(counts?.past),
|
||||
current: toNonNegativeInteger(counts?.current),
|
||||
future: toNonNegativeInteger(counts?.future),
|
||||
};
|
||||
};
|
||||
|
||||
const buildDepartmentBookingFilters = (departmentId) => `department:${departmentId}`;
|
||||
|
||||
const getBookingsResponseData = (response) => {
|
||||
const bookings = response?.data?.data;
|
||||
return Array.isArray(bookings) ? bookings : [];
|
||||
};
|
||||
|
||||
const getBookingDepartmentId = (booking) => {
|
||||
return normalizePositiveInteger(booking?.department_id ?? booking?.department);
|
||||
};
|
||||
|
||||
const isPendingBooking = (booking) => {
|
||||
const rawOrderId = booking?.order_id;
|
||||
|
||||
if (rawOrderId === null || rawOrderId === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof rawOrderId === "number") {
|
||||
return rawOrderId <= 0;
|
||||
}
|
||||
|
||||
if (typeof rawOrderId === "string") {
|
||||
const trimmedOrderId = rawOrderId.trim();
|
||||
if (trimmedOrderId === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsedOrderId = Number.parseInt(trimmedOrderId, 10);
|
||||
return Number.isInteger(parsedOrderId) ? parsedOrderId <= 0 : false;
|
||||
}
|
||||
|
||||
return !rawOrderId;
|
||||
};
|
||||
|
||||
const getBookingBucket = (bookingDate, { todayStartDate, todayEndDate }) => {
|
||||
if (bookingDate.getTime() < todayStartDate.getTime()) {
|
||||
return "past";
|
||||
}
|
||||
|
||||
if (bookingDate.getTime() > todayEndDate.getTime()) {
|
||||
return "future";
|
||||
}
|
||||
|
||||
return "current";
|
||||
};
|
||||
|
||||
const fetchDepartmentBookings = async (departmentId) => {
|
||||
const bookings = [];
|
||||
|
||||
for (let page = 1; page <= MAX_BOOKING_COUNT_PAGES; page += 1) {
|
||||
const response = await authenticatedRequest("/order-bookings", "GET", {
|
||||
filters: buildDepartmentBookingFilters(departmentId),
|
||||
page,
|
||||
limit: BOOKING_COUNT_PAGE_SIZE,
|
||||
search: "",
|
||||
order: "datetime:DESC",
|
||||
});
|
||||
const pageBookings = getBookingsResponseData(response);
|
||||
bookings.push(...pageBookings);
|
||||
|
||||
if (pageBookings.length < BOOKING_COUNT_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return bookings;
|
||||
};
|
||||
|
||||
export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
|
||||
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
|
||||
|
||||
@@ -113,39 +32,11 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const dayBoundaries = buildDayBoundaries();
|
||||
const bookings = await fetchDepartmentBookings(normalizedDepartmentId);
|
||||
const counts = bookings.reduce(
|
||||
(accumulator, booking) => {
|
||||
if (!isPendingBooking(booking)) {
|
||||
return accumulator;
|
||||
}
|
||||
const response = await authenticatedRequest("/order-bookings/counts", "GET", {
|
||||
department: normalizedDepartmentId,
|
||||
});
|
||||
|
||||
if (getBookingDepartmentId(booking) !== normalizedDepartmentId) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const bookingDate = toBookingDate(booking?.datetime);
|
||||
if (bookingDate === null) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const bucket = getBookingBucket(bookingDate, dayBoundaries);
|
||||
accumulator[bucket] += 1;
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
past: 0,
|
||||
current: 0,
|
||||
future: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
past: counts.past,
|
||||
current: counts.current,
|
||||
future: counts.future,
|
||||
};
|
||||
return normalizeBookingCounts(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching department order-booking counts:", error);
|
||||
return {
|
||||
|
||||
@@ -1,121 +1,24 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
const BOOKING_COUNT_PAGE_SIZE = 100;
|
||||
const MAX_BOOKING_COUNT_PAGES = 100;
|
||||
|
||||
const toBookingDate = (value) => {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedValue = value.includes("T") ? value : value.replace(" ", "T");
|
||||
const parsedDate = new Date(normalizedValue);
|
||||
|
||||
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
|
||||
const toNonNegativeInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : 0;
|
||||
};
|
||||
|
||||
const buildDayBoundaries = () => {
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date();
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
const normalizeBookingCounts = (response) => {
|
||||
const counts = response?.data?.data;
|
||||
|
||||
return {
|
||||
todayStartDate: todayStart,
|
||||
todayEndDate: todayEnd,
|
||||
past: toNonNegativeInteger(counts?.past),
|
||||
current: toNonNegativeInteger(counts?.current),
|
||||
future: toNonNegativeInteger(counts?.future),
|
||||
};
|
||||
};
|
||||
|
||||
const getBookingsResponseData = (response) => {
|
||||
const bookings = response?.data?.data;
|
||||
return Array.isArray(bookings) ? bookings : [];
|
||||
};
|
||||
|
||||
const isPendingBooking = (booking) => {
|
||||
const rawOrderId = booking?.order_id;
|
||||
|
||||
if (rawOrderId === null || rawOrderId === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof rawOrderId === "number") {
|
||||
return rawOrderId <= 0;
|
||||
}
|
||||
|
||||
if (typeof rawOrderId === "string") {
|
||||
const trimmedOrderId = rawOrderId.trim();
|
||||
if (trimmedOrderId === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsedOrderId = Number.parseInt(trimmedOrderId, 10);
|
||||
return Number.isInteger(parsedOrderId) ? parsedOrderId <= 0 : false;
|
||||
}
|
||||
|
||||
return !rawOrderId;
|
||||
};
|
||||
|
||||
const getBookingBucket = (bookingDate, { todayStartDate, todayEndDate }) => {
|
||||
if (bookingDate.getTime() < todayStartDate.getTime()) {
|
||||
return "past";
|
||||
}
|
||||
|
||||
if (bookingDate.getTime() > todayEndDate.getTime()) {
|
||||
return "future";
|
||||
}
|
||||
|
||||
return "current";
|
||||
};
|
||||
|
||||
const fetchSuperUserBookings = async () => {
|
||||
const bookings = [];
|
||||
|
||||
for (let page = 1; page <= MAX_BOOKING_COUNT_PAGES; page += 1) {
|
||||
const response = await authenticatedRequest("/order-bookings", "GET", {
|
||||
filters: "",
|
||||
page,
|
||||
limit: BOOKING_COUNT_PAGE_SIZE,
|
||||
search: "",
|
||||
order: "datetime:DESC",
|
||||
});
|
||||
const pageBookings = getBookingsResponseData(response);
|
||||
bookings.push(...pageBookings);
|
||||
|
||||
if (pageBookings.length < BOOKING_COUNT_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return bookings;
|
||||
};
|
||||
|
||||
export const fetchSuperUserBookingCounts = async () => {
|
||||
try {
|
||||
const dayBoundaries = buildDayBoundaries();
|
||||
const bookings = await fetchSuperUserBookings();
|
||||
|
||||
return bookings.reduce(
|
||||
(accumulator, booking) => {
|
||||
if (!isPendingBooking(booking)) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const bookingDate = toBookingDate(booking?.datetime);
|
||||
if (bookingDate === null) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const bucket = getBookingBucket(bookingDate, dayBoundaries);
|
||||
accumulator[bucket] += 1;
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
past: 0,
|
||||
current: 0,
|
||||
future: 0,
|
||||
}
|
||||
);
|
||||
const response = await authenticatedRequest("/order-bookings/counts", "GET", {});
|
||||
return normalizeBookingCounts(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching superuser booking counts:", error);
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { BIcon, BMenu, BMenuItem, BMenuList } from "buefy";
|
||||
import { BIcon, BMenu, BMenuItem, BMenuList, BTooltip } from "buefy";
|
||||
import type { NavigationItemProps } from "@/components/models/navigation/NavigationItem.vue";
|
||||
import { useNavigationItems } from "@/components/models/navigation/items/NavigationMenuItems.vue";
|
||||
import { ADMIN_DEPARTMENT_SELECTION_CLASS } from "@/components/models/navigation/items/NavigationMenuItemsAdmin.vue";
|
||||
@@ -77,8 +77,6 @@ const getVisibleBadges = (item: NavigationMenuItem) => {
|
||||
return getBadges(item).filter((badge) => badge?.condition === true);
|
||||
};
|
||||
|
||||
const hasBadge = (item: NavigationMenuItem) => getVisibleBadges(item).length > 0;
|
||||
|
||||
const getBadgeText = (badge: NavigationBadge) => String(badge?.text ?? "");
|
||||
|
||||
const getBadgeClasses = (badge: NavigationBadge) => {
|
||||
@@ -89,11 +87,77 @@ const getBadgeTestId = (badge: NavigationBadge, fallback: string | null = null)
|
||||
return badge?.testId ?? fallback;
|
||||
};
|
||||
|
||||
const getBadgeTooltipText = (badge: NavigationBadge) => String(badge?.tooltip ?? "").trim();
|
||||
|
||||
const hasBadgeTooltip = (badge: NavigationBadge) => getBadgeTooltipText(badge).length > 0;
|
||||
|
||||
const getBadgeTooltipTestId = (badge: NavigationBadge) => {
|
||||
if (badge?.tooltipTestId) {
|
||||
return badge.tooltipTestId;
|
||||
}
|
||||
|
||||
return badge?.testId ? `${badge.testId}-tooltip` : null;
|
||||
};
|
||||
|
||||
const isBookingsNavigationItem = (item: NavigationMenuItem) => {
|
||||
const target = String(item?.to ?? "");
|
||||
return target.includes("/modules/bookings") || target === "/superuser/bookings";
|
||||
};
|
||||
|
||||
const isBookingsLoadingBadge = (badge: NavigationBadge) => getBadgeTestId(badge) === "desktop-buefy-nav-bookings-badge-loading";
|
||||
const isBookingsCurrentBadge = (badge: NavigationBadge) => getBadgeTestId(badge) === "desktop-buefy-nav-bookings-badge";
|
||||
const isBookingsOverdueBadge = (badge: NavigationBadge) => getBadgeTestId(badge) === "desktop-buefy-nav-bookings-badge-overdue";
|
||||
const isBookingsFutureBadge = (badge: NavigationBadge) => getBadgeTestId(badge) === "desktop-buefy-nav-bookings-badge-future";
|
||||
|
||||
const getInlineBadges = (item: NavigationMenuItem) => {
|
||||
const badges = getVisibleBadges(item);
|
||||
if (!isBookingsNavigationItem(item)) {
|
||||
return badges;
|
||||
}
|
||||
|
||||
return badges.filter((badge) => isBookingsLoadingBadge(badge) || isBookingsCurrentBadge(badge));
|
||||
};
|
||||
|
||||
const getTooltipBadges = (item: NavigationMenuItem) => {
|
||||
if (!isBookingsNavigationItem(item)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getVisibleBadges(item).filter((badge) => isBookingsOverdueBadge(badge) || isBookingsFutureBadge(badge));
|
||||
};
|
||||
|
||||
const hasInlineBadge = (item: NavigationMenuItem) => getInlineBadges(item).length > 0;
|
||||
const hasTooltipBadges = (item: NavigationMenuItem) => getTooltipBadges(item).length > 0;
|
||||
const hasLabelTooltip = (item: NavigationMenuItem) => hasTooltipBadges(item) && !hasInlineBadge(item);
|
||||
|
||||
const hasBookingsBadgeBreakdownTooltip = (item: NavigationMenuItem, badge: NavigationBadge) => {
|
||||
return isBookingsNavigationItem(item) && isBookingsCurrentBadge(badge) && hasTooltipBadges(item);
|
||||
};
|
||||
|
||||
const getBookingsTooltipLabel = (badge: NavigationBadge) => {
|
||||
if (isBookingsOverdueBadge(badge)) {
|
||||
return "Tidligere";
|
||||
}
|
||||
|
||||
if (isBookingsFutureBadge(badge)) {
|
||||
return "Fremtidige";
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const getBookingsTooltipTestId = (badge: NavigationBadge) => {
|
||||
if (isBookingsOverdueBadge(badge)) {
|
||||
return "desktop-buefy-nav-bookings-tooltip-overdue";
|
||||
}
|
||||
|
||||
if (isBookingsFutureBadge(badge)) {
|
||||
return "desktop-buefy-nav-bookings-tooltip-future";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getItemLabelTestId = (item: NavigationMenuItem) => {
|
||||
return isBookingsNavigationItem(item) ? "desktop-buefy-nav-bookings-label" : null;
|
||||
};
|
||||
@@ -134,22 +198,120 @@ const getChildBadgeTestId = (item: NavigationMenuItem) => (isDraftsNavigationChi
|
||||
:class="[item.classes ? item.classes.join(' ') : '', 'is-size-5']"
|
||||
>
|
||||
<template #label>
|
||||
<BTooltip
|
||||
v-if="hasLabelTooltip(item)"
|
||||
class="desktop-buefy-fullwidth-tooltip-trigger"
|
||||
position="is-right"
|
||||
type="is-dark"
|
||||
multilined
|
||||
append-to-body
|
||||
>
|
||||
<template #content>
|
||||
<div class="desktop-buefy-bookings-tooltip" data-testid="desktop-buefy-nav-bookings-tooltip">
|
||||
<div
|
||||
v-for="(badge, badgeIndex) in getTooltipBadges(item)"
|
||||
:key="`${item.to || item.label}-tooltip-badge-${badgeIndex}-${getBadgeText(badge)}`"
|
||||
class="desktop-buefy-bookings-tooltip-row"
|
||||
:data-testid="getBookingsTooltipTestId(badge)"
|
||||
>
|
||||
<span class="desktop-buefy-bookings-tooltip-label">{{ getBookingsTooltipLabel(badge) }}</span>
|
||||
<span
|
||||
class="tag is-rounded is-small desktop-buefy-bookings-tooltip-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="desktop-buefy-item-label"
|
||||
:class="{ 'desktop-buefy-item-label-with-badge': hasInlineBadge(item) }"
|
||||
:data-testid="getItemLabelTestId(item)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="desktop-buefy-item-label-trailing">
|
||||
<BIcon pack="fas" :icon="'chevron-right'" />
|
||||
</span>
|
||||
</span>
|
||||
</BTooltip>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="desktop-buefy-item-label"
|
||||
:class="{ 'desktop-buefy-item-label-with-badge': hasBadge(item) }"
|
||||
:class="{ 'desktop-buefy-item-label-with-badge': hasInlineBadge(item) }"
|
||||
:data-testid="getItemLabelTestId(item)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="desktop-buefy-item-label-trailing">
|
||||
<span
|
||||
v-for="(badge, badgeIndex) in getVisibleBadges(item)"
|
||||
<template
|
||||
v-for="(badge, badgeIndex) in getInlineBadges(item)"
|
||||
:key="`${item.to || item.label}-badge-${badgeIndex}-${getBadgeText(badge)}`"
|
||||
class="tag is-rounded is-small desktop-buefy-item-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
:data-testid="getBadgeTestId(badge)"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
<BTooltip
|
||||
v-if="hasBookingsBadgeBreakdownTooltip(item, badge)"
|
||||
position="is-right"
|
||||
type="is-dark"
|
||||
multilined
|
||||
append-to-body
|
||||
>
|
||||
<template #content>
|
||||
<div class="desktop-buefy-bookings-tooltip" data-testid="desktop-buefy-nav-bookings-tooltip">
|
||||
<div
|
||||
v-for="(tooltipBadge, tooltipBadgeIndex) in getTooltipBadges(item)"
|
||||
:key="`${item.to || item.label}-tooltip-badge-${tooltipBadgeIndex}-${getBadgeText(tooltipBadge)}`"
|
||||
class="desktop-buefy-bookings-tooltip-row"
|
||||
:data-testid="getBookingsTooltipTestId(tooltipBadge)"
|
||||
>
|
||||
<span class="desktop-buefy-bookings-tooltip-label">{{ getBookingsTooltipLabel(tooltipBadge) }}</span>
|
||||
<span
|
||||
class="tag is-rounded is-small desktop-buefy-bookings-tooltip-badge"
|
||||
:class="getBadgeClasses(tooltipBadge)"
|
||||
>
|
||||
{{ getBadgeText(tooltipBadge) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="tag is-rounded is-small desktop-buefy-item-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
:data-testid="getBadgeTestId(badge)"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
</BTooltip>
|
||||
|
||||
<BTooltip
|
||||
v-else-if="hasBadgeTooltip(badge)"
|
||||
position="is-right"
|
||||
type="is-dark"
|
||||
append-to-body
|
||||
>
|
||||
<template #content>
|
||||
<span :data-testid="getBadgeTooltipTestId(badge)">{{ getBadgeTooltipText(badge) }}</span>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="tag is-rounded is-small desktop-buefy-item-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
:data-testid="getBadgeTestId(badge)"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
</BTooltip>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="tag is-rounded is-small desktop-buefy-item-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
:data-testid="getBadgeTestId(badge)"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
</template>
|
||||
<BIcon pack="fas" :icon="'chevron-right'" />
|
||||
</span>
|
||||
</span>
|
||||
@@ -166,22 +328,118 @@ const getChildBadgeTestId = (item: NavigationMenuItem) => (isDraftsNavigationChi
|
||||
:class="[child.classes ? child.classes.join(' ') : '', 'is-size-5']"
|
||||
>
|
||||
<template #label>
|
||||
<BTooltip
|
||||
v-if="hasLabelTooltip(child)"
|
||||
class="desktop-buefy-fullwidth-tooltip-trigger"
|
||||
position="is-right"
|
||||
type="is-dark"
|
||||
multilined
|
||||
append-to-body
|
||||
>
|
||||
<template #content>
|
||||
<div class="desktop-buefy-bookings-tooltip" data-testid="desktop-buefy-nav-bookings-tooltip">
|
||||
<div
|
||||
v-for="(badge, badgeIndex) in getTooltipBadges(child)"
|
||||
:key="`${child.to || child.label}-tooltip-badge-${badgeIndex}-${getBadgeText(badge)}`"
|
||||
class="desktop-buefy-bookings-tooltip-row"
|
||||
:data-testid="getBookingsTooltipTestId(badge)"
|
||||
>
|
||||
<span class="desktop-buefy-bookings-tooltip-label">{{ getBookingsTooltipLabel(badge) }}</span>
|
||||
<span
|
||||
class="tag is-rounded is-small desktop-buefy-bookings-tooltip-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="desktop-buefy-child-label"
|
||||
:class="{ 'desktop-buefy-child-label-with-badge': hasInlineBadge(child) }"
|
||||
:data-testid="getChildLabelTestId(child)"
|
||||
>
|
||||
<span>{{ child.label }}</span>
|
||||
<span class="desktop-buefy-child-label-trailing"></span>
|
||||
</span>
|
||||
</BTooltip>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="desktop-buefy-child-label"
|
||||
:class="{ 'desktop-buefy-child-label-with-badge': hasBadge(child) }"
|
||||
:class="{ 'desktop-buefy-child-label-with-badge': hasInlineBadge(child) }"
|
||||
:data-testid="getChildLabelTestId(child)"
|
||||
>
|
||||
<span>{{ child.label }}</span>
|
||||
<span class="desktop-buefy-child-label-trailing">
|
||||
<span
|
||||
v-for="(badge, badgeIndex) in getVisibleBadges(child)"
|
||||
<template
|
||||
v-for="(badge, badgeIndex) in getInlineBadges(child)"
|
||||
:key="`${child.to || child.label}-badge-${badgeIndex}-${getBadgeText(badge)}`"
|
||||
class="tag is-rounded is-small desktop-buefy-child-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
:data-testid="getBadgeTestId(badge, getChildBadgeTestId(child))"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
<BTooltip
|
||||
v-if="hasBookingsBadgeBreakdownTooltip(child, badge)"
|
||||
position="is-right"
|
||||
type="is-dark"
|
||||
multilined
|
||||
append-to-body
|
||||
>
|
||||
<template #content>
|
||||
<div class="desktop-buefy-bookings-tooltip" data-testid="desktop-buefy-nav-bookings-tooltip">
|
||||
<div
|
||||
v-for="(tooltipBadge, tooltipBadgeIndex) in getTooltipBadges(child)"
|
||||
:key="`${child.to || child.label}-tooltip-badge-${tooltipBadgeIndex}-${getBadgeText(tooltipBadge)}`"
|
||||
class="desktop-buefy-bookings-tooltip-row"
|
||||
:data-testid="getBookingsTooltipTestId(tooltipBadge)"
|
||||
>
|
||||
<span class="desktop-buefy-bookings-tooltip-label">{{ getBookingsTooltipLabel(tooltipBadge) }}</span>
|
||||
<span
|
||||
class="tag is-rounded is-small desktop-buefy-bookings-tooltip-badge"
|
||||
:class="getBadgeClasses(tooltipBadge)"
|
||||
>
|
||||
{{ getBadgeText(tooltipBadge) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="tag is-rounded is-small desktop-buefy-child-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
:data-testid="getBadgeTestId(badge, getChildBadgeTestId(child))"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
</BTooltip>
|
||||
|
||||
<BTooltip
|
||||
v-else-if="hasBadgeTooltip(badge)"
|
||||
position="is-right"
|
||||
type="is-dark"
|
||||
append-to-body
|
||||
>
|
||||
<template #content>
|
||||
<span :data-testid="getBadgeTooltipTestId(badge)">{{ getBadgeTooltipText(badge) }}</span>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="tag is-rounded is-small desktop-buefy-child-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
:data-testid="getBadgeTestId(badge, getChildBadgeTestId(child))"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
</BTooltip>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="tag is-rounded is-small desktop-buefy-child-badge"
|
||||
:class="getBadgeClasses(badge)"
|
||||
:data-testid="getBadgeTestId(badge, getChildBadgeTestId(child))"
|
||||
>
|
||||
{{ getBadgeText(badge) }}
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -226,6 +484,43 @@ const getChildBadgeTestId = (item: NavigationMenuItem) => (isDraftsNavigationChi
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
|
||||
.desktop-buefy-fullwidth-tooltip-trigger {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desktop-buefy-fullwidth-tooltip-trigger :deep(.tooltip-trigger) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desktop-buefy-bookings-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.desktop-buefy-bookings-tooltip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.desktop-buefy-bookings-tooltip-label {
|
||||
color: #f9fafb;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.desktop-buefy-bookings-tooltip-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
|
||||
.desktop-buefy-bookings-badge-future {
|
||||
background-color: #d7e8ff !important;
|
||||
color: #1f5a99 !important;
|
||||
|
||||
@@ -72,6 +72,8 @@ const closed_at = ref(null);
|
||||
const doesOrderExist = ref(false);
|
||||
const CARD_PAYMENT_CUSTOMER_ID = 999;
|
||||
const isSwitchingDraftToCardPayment = ref(false);
|
||||
const isCancellingStripeInvoice = ref(false);
|
||||
const STRIPE_HOSTED_INVOICE_TERMINAL_STATUSES = ['paid', 'void', 'uncollectible', 'deleted'];
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
@@ -182,6 +184,10 @@ const showAddOrderItemsDisplay = (show) => {
|
||||
};
|
||||
|
||||
const openStripeInvoice = () => {
|
||||
if (!stripeModule.value?.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(stripeModule.value.url, '_blank');
|
||||
};
|
||||
|
||||
@@ -208,6 +214,108 @@ const getStripeInvoiceStatus = () => {
|
||||
const isStripeInvoicePaid = () => {
|
||||
return stripeModule.value.paid;
|
||||
};
|
||||
|
||||
const getStripeHostedInvoiceStatusKey = () => {
|
||||
const status = String(stripeModule.value?.status ?? '').trim().toLowerCase();
|
||||
|
||||
switch (status) {
|
||||
case 'open':
|
||||
case 'paid':
|
||||
case 'void':
|
||||
case 'uncollectible':
|
||||
case 'deleted':
|
||||
case 'draft':
|
||||
return status;
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getStripeHostedInvoiceStatusLabel = () => {
|
||||
return t(`admin.pos.stripe.email.states.${getStripeHostedInvoiceStatusKey()}`);
|
||||
};
|
||||
|
||||
const getStripeHostedInvoiceStatusToneClass = () => {
|
||||
switch (getStripeHostedInvoiceStatusKey()) {
|
||||
case 'paid':
|
||||
return 'is-success';
|
||||
case 'open':
|
||||
case 'draft':
|
||||
return 'is-warning';
|
||||
case 'void':
|
||||
case 'uncollectible':
|
||||
case 'deleted':
|
||||
return 'is-light';
|
||||
default:
|
||||
return 'is-light';
|
||||
}
|
||||
};
|
||||
|
||||
const isStripeHostedInvoiceTerminal = () => {
|
||||
return STRIPE_HOSTED_INVOICE_TERMINAL_STATUSES.includes(getStripeHostedInvoiceStatusKey());
|
||||
};
|
||||
|
||||
const isStripeHostedInvoiceOpen = () => {
|
||||
return isInvoicedWithStripe() && !isStripeHostedInvoiceTerminal();
|
||||
};
|
||||
|
||||
const getStripeHostedInvoiceHelpText = () => {
|
||||
if (!isInvoicedWithStripe()) {
|
||||
return t('admin.pos.stripe.email.help');
|
||||
}
|
||||
|
||||
if (isStripeInvoicePaid()) {
|
||||
return t('admin.pos.stripe.email.messages.paid');
|
||||
}
|
||||
|
||||
if (isStripeHostedInvoiceTerminal()) {
|
||||
return t('admin.pos.stripe.email.messages.closed');
|
||||
}
|
||||
|
||||
return t('admin.pos.stripe.email.messages.sent');
|
||||
};
|
||||
|
||||
const shouldShowStripeHostedInvoicePanel = () => {
|
||||
return isStripeHostedInvoiceOpen();
|
||||
};
|
||||
|
||||
const shouldShowReceiptAction = () => {
|
||||
return !isEconomicExportBlocked.value && !isStripeHostedInvoiceOpen();
|
||||
};
|
||||
|
||||
const shouldShowStripeInvoiceAction = () => {
|
||||
return isInvoicedWithStripe() && !isStripeHostedInvoiceOpen();
|
||||
};
|
||||
|
||||
const shouldShowCompleteOrderAction = () => {
|
||||
return !isCompleted() && !isStripeHostedInvoiceOpen();
|
||||
};
|
||||
|
||||
const cancelOpenStripeInvoice = async () => {
|
||||
const normalizedOrderId = normalizePositiveInteger(order.value?.id ?? orderId.value);
|
||||
if (!normalizedOrderId || isCancellingStripeInvoice.value || !isStripeHostedInvoiceOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCancellingStripeInvoice.value = true;
|
||||
|
||||
try {
|
||||
await authenticatedRequest('/modules/stripe/invoice', 'DELETE', {
|
||||
order_id: normalizedOrderId,
|
||||
});
|
||||
await loadOrder();
|
||||
await getInvoiceCollection();
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('common.error'),
|
||||
text: SessionUser.functions.parseErrorMessage(error) || t('admin.pos.stripe.email.errors.cancel'),
|
||||
});
|
||||
} finally {
|
||||
isCancellingStripeInvoice.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const isShowingPrintReceipt = ref(false);
|
||||
|
||||
// Load the order, when the page is loaded
|
||||
@@ -1210,7 +1318,7 @@ const isDisplayingReceipt = () => {
|
||||
class="is-clickable"
|
||||
@click="SessionUser.functions.redirectTo.department(
|
||||
order.department_id,
|
||||
'modules/bookings/' + order.booking_id)"
|
||||
'modules/bookings/order/' + order.booking_id)"
|
||||
>
|
||||
<p>
|
||||
{{ $t('pos.order.linked_booking_id') }}: {{ order.booking_id }}
|
||||
@@ -1284,8 +1392,62 @@ const isDisplayingReceipt = () => {
|
||||
<span class="icon"><i class="fas fa-credit-card"></i></span>
|
||||
<span>{{ t("pos.customer_picker.select_card_payment") }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="shouldShowStripeHostedInvoicePanel()"
|
||||
class="pos-order-stripe-email-panel"
|
||||
data-testid="pos-order-stripe-email-panel"
|
||||
>
|
||||
<div class="pos-order-stripe-email-panel__header">
|
||||
<div>
|
||||
<p class="pos-order-stripe-email-panel__eyebrow">{{ t('admin.pos.stripe.email.eyebrow') }}</p>
|
||||
<p class="pos-order-stripe-email-panel__title">{{ t('admin.pos.stripe.email.title') }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="tag pos-order-stripe-email-panel__status-tag"
|
||||
:class="getStripeHostedInvoiceStatusToneClass()"
|
||||
data-testid="pos-order-stripe-email-status"
|
||||
>
|
||||
{{ getStripeHostedInvoiceStatusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="pos-order-stripe-email-panel__help">
|
||||
{{ getStripeHostedInvoiceHelpText() }}
|
||||
</p>
|
||||
|
||||
<div class="pos-order-stripe-email-panel__meta">
|
||||
<div class="pos-order-stripe-email-panel__meta-row">
|
||||
<span>{{ t('admin.pos.stripe.email.status_label') }}</span>
|
||||
<strong>{{ getStripeHostedInvoiceStatusLabel() }}</strong>
|
||||
</div>
|
||||
<div v-if="stripeModule?.url" class="pos-order-stripe-email-panel__meta-row">
|
||||
<span>{{ t('admin.pos.stripe.email.invoice_link_label') }}</span>
|
||||
<a
|
||||
:href="stripeModule.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="pos-order-stripe-email-link"
|
||||
>
|
||||
{{ t('admin.pos.stripe.email.invoice_link_action') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pos-order-stripe-email-panel__actions">
|
||||
<button
|
||||
class="button is-light is-fullwidth"
|
||||
type="button"
|
||||
data-testid="pos-order-stripe-email-cancel"
|
||||
:class="{ 'is-loading': isCancellingStripeInvoice }"
|
||||
:disabled="isCancellingStripeInvoice"
|
||||
@click="cancelOpenStripeInvoice"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isEconomicExportBlocked"
|
||||
v-if="shouldShowReceiptAction()"
|
||||
class="button is-dark is-fullwidth"
|
||||
data-testid="pos-order-print-receipt"
|
||||
type="button"
|
||||
@@ -1295,14 +1457,14 @@ const isDisplayingReceipt = () => {
|
||||
<span>{{ SessionUser.objects.global.language.payment.receipt }}</span>
|
||||
</button>
|
||||
<ExportOrderToInvoiceStripeButton :order_id="orderId" class="is-fullwidth" v-if="!isInvoiced() && invoiceUsingStripe() && isPaymentMethodStripe()" />
|
||||
<button class="button is-dark is-fullwidth" @click="openStripeInvoice" v-if="isInvoicedWithStripe()">
|
||||
<button class="button is-dark is-fullwidth" @click="openStripeInvoice" v-if="shouldShowStripeInvoiceAction()">
|
||||
<span class="icon"><i class="fas fa-file-invoice"></i></span>
|
||||
<span>{{ $t('pos.order.get_invoice') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-success is-fullwidth"
|
||||
@click="SessionUser.objects.orders.functions.mark_as_completed(orderId) && loadOrder()"
|
||||
v-if="!isCompleted()"
|
||||
v-if="shouldShowCompleteOrderAction()"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-check"></i></span>
|
||||
<span>{{ $t('pos.order.complete_order') }}</span>
|
||||
@@ -1475,6 +1637,69 @@ const isDisplayingReceipt = () => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel {
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 249, 251, 0.98) 100%);
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel__title {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #17375c;
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel__status-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel__help {
|
||||
margin: 0.85rem 0 0;
|
||||
color: #526581;
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel__meta,
|
||||
.pos-order-stripe-email-panel__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel__meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel__meta-row span {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pos-order-stripe-email-panel__meta-row a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
.pos-order-detail__workspace--editing {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -294,6 +294,8 @@ test.describe("Admin department visibility", () => {
|
||||
|
||||
await expect(draftsLabel).toContainText("Kladder");
|
||||
await expect(draftsBadge).toHaveText("3");
|
||||
await draftsBadge.hover();
|
||||
await expect(page.getByTestId("desktop-buefy-nav-drafts-badge-tooltip")).toContainText("Kladder");
|
||||
|
||||
await page.goto("/admin/2/modules/daily-report");
|
||||
|
||||
@@ -380,9 +382,7 @@ test.describe("Admin department visibility", () => {
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(9, 0, 0, 0);
|
||||
const requestedFilters: string[] = [];
|
||||
const requestedDepartments: string[] = [];
|
||||
|
||||
await mockAdminDepartmentDependencies(page, {
|
||||
permissions: [
|
||||
@@ -391,27 +391,19 @@ test.describe("Admin department visibility", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await page.route(/\/order-bookings(\?.*)?$/i, async (route) => {
|
||||
await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const filters = url.searchParams.get("filters") || "";
|
||||
requestedFilters.push(filters);
|
||||
const isDepartmentOne = filters.includes("department:1");
|
||||
const department = url.searchParams.get("department") || "";
|
||||
requestedDepartments.push(department);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data:
|
||||
isDepartmentOne
|
||||
? [
|
||||
{ id: 801, department: 1, order_id: "", datetime: formatLocalDateTime(today) },
|
||||
]
|
||||
: [],
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 0,
|
||||
},
|
||||
data: {
|
||||
past: 0,
|
||||
current: department === "1" ? 1 : 0,
|
||||
future: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -424,11 +416,9 @@ test.describe("Admin department visibility", () => {
|
||||
|
||||
await expect(bookingsLabel).toContainText("Bookinger");
|
||||
await expect(currentBookingsBadge).toHaveText("1");
|
||||
expect(
|
||||
requestedFilters.some(
|
||||
(filters) => filters.includes("department:1") && !filters.includes("datetime-date_from:") && !filters.includes("datetime-date_to:")
|
||||
)
|
||||
).toBe(true);
|
||||
await currentBookingsBadge.hover();
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-badge-tooltip")).toContainText("I dag");
|
||||
expect(requestedDepartments).toContain("1");
|
||||
});
|
||||
|
||||
test("resolves the desktop buefy bookings loader even when the count request is slower than the poll interval", async ({
|
||||
@@ -436,9 +426,6 @@ test.describe("Admin department visibility", () => {
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(9, 0, 0, 0);
|
||||
|
||||
await mockAdminDepartmentDependencies(page, {
|
||||
permissions: [
|
||||
...adminPermissions,
|
||||
@@ -446,13 +433,10 @@ test.describe("Admin department visibility", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await page.route(/\/order-bookings(\?.*)?$/i, async (route) => {
|
||||
await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const filters = url.searchParams.get("filters") || "";
|
||||
const isDepartmentOneNavigationCountRequest =
|
||||
filters.includes("department:1")
|
||||
&& !filters.includes("datetime-date_from:")
|
||||
&& !filters.includes("datetime-date_to:");
|
||||
const department = url.searchParams.get("department") || "";
|
||||
const isDepartmentOneNavigationCountRequest = department === "1";
|
||||
|
||||
if (isDepartmentOneNavigationCountRequest) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5500));
|
||||
@@ -460,18 +444,10 @@ test.describe("Admin department visibility", () => {
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data:
|
||||
isDepartmentOneNavigationCountRequest
|
||||
? [
|
||||
{ id: 8901, department: 1, order_id: "", datetime: formatLocalDateTime(today) },
|
||||
{ id: 8902, department: 1, order_id: "", datetime: formatLocalDateTime(new Date(today.getTime() + 60 * 60 * 1000)) },
|
||||
]
|
||||
: [],
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 0,
|
||||
},
|
||||
data: {
|
||||
past: 0,
|
||||
current: isDepartmentOneNavigationCountRequest ? 2 : 0,
|
||||
future: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -488,32 +464,11 @@ test.describe("Admin department visibility", () => {
|
||||
await expect(loadingBadge).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows department-scoped past, current, and future order-booking badges in the desktop buefy menu", async ({
|
||||
test("shows only the current order-booking badge inline and exposes past and future in a tooltip", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
const requestedFilters: string[] = [];
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(9, 0, 0, 0);
|
||||
|
||||
const todayLate = new Date(today);
|
||||
todayLate.setHours(10, 0, 0, 0);
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
const yesterdayLate = new Date(yesterday);
|
||||
yesterdayLate.setHours(10, 0, 0, 0);
|
||||
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
|
||||
const tomorrowLate = new Date(tomorrow);
|
||||
tomorrowLate.setHours(10, 0, 0, 0);
|
||||
|
||||
const dayAfterTomorrow = new Date(today);
|
||||
dayAfterTomorrow.setDate(today.getDate() + 2);
|
||||
const requestedDepartments: string[] = [];
|
||||
|
||||
await mockAdminDepartmentDependencies(page, {
|
||||
permissions: [
|
||||
@@ -522,36 +477,24 @@ test.describe("Admin department visibility", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await page.route(/\/order-bookings(\?.*)?$/i, async (route) => {
|
||||
await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const filters = url.searchParams.get("filters") || "";
|
||||
requestedFilters.push(filters);
|
||||
const isDepartmentOne = filters.includes("department:1");
|
||||
const bookings =
|
||||
isDepartmentOne
|
||||
? [
|
||||
{ id: 701, department: 1, order_id: "", datetime: formatLocalDateTime(yesterday) },
|
||||
{ id: 702, department: 1, order_id: 2001, datetime: formatLocalDateTime(yesterdayLate) },
|
||||
{ id: 703, department: 1, order_id: "", datetime: formatLocalDateTime(new Date(yesterday.getTime() - 60 * 60 * 1000)) },
|
||||
{ id: 704, department: 1, order_id: "", datetime: formatLocalDateTime(today) },
|
||||
{ id: 705, department: 1, order_id: "", datetime: formatLocalDateTime(todayLate) },
|
||||
{ id: 706, department: 1, order_id: "", datetime: formatLocalDateTime(new Date(todayLate.getTime() + 60 * 60 * 1000)) },
|
||||
{ id: 707, department: 1, order_id: "", datetime: formatLocalDateTime(tomorrow) },
|
||||
{ id: 708, department: 1, order_id: 2003, datetime: formatLocalDateTime(tomorrowLate) },
|
||||
{ id: 709, department: 1, order_id: 2004, datetime: formatLocalDateTime(dayAfterTomorrow) },
|
||||
{ id: 710, department: 2, order_id: "", datetime: formatLocalDateTime(today) },
|
||||
]
|
||||
: [];
|
||||
const department = url.searchParams.get("department") || "";
|
||||
requestedDepartments.push(department);
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: bookings,
|
||||
meta: {
|
||||
pagination: {
|
||||
total: bookings.length > 0 ? 1 : 0,
|
||||
},
|
||||
},
|
||||
data: department === "1"
|
||||
? {
|
||||
past: 2,
|
||||
current: 3,
|
||||
future: 1,
|
||||
}
|
||||
: {
|
||||
past: 0,
|
||||
current: 0,
|
||||
future: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -562,16 +505,18 @@ test.describe("Admin department visibility", () => {
|
||||
const overdueBookingsBadge = page.getByTestId("desktop-buefy-nav-bookings-badge-overdue");
|
||||
const currentBookingsBadge = page.getByTestId("desktop-buefy-nav-bookings-badge");
|
||||
const futureBookingsBadge = page.getByTestId("desktop-buefy-nav-bookings-badge-future");
|
||||
const bookingsTooltip = page.getByTestId("desktop-buefy-nav-bookings-tooltip");
|
||||
|
||||
await expect(bookingsLabel).toContainText("Bookinger");
|
||||
await expect(overdueBookingsBadge).toHaveText("2");
|
||||
await expect(currentBookingsBadge).toHaveText("3");
|
||||
await expect(futureBookingsBadge).toHaveText("1");
|
||||
expect(
|
||||
requestedFilters.some(
|
||||
(filters) => filters.includes("department:1") && !filters.includes("datetime-date_from:") && !filters.includes("datetime-date_to:")
|
||||
)
|
||||
).toBe(true);
|
||||
await expect(overdueBookingsBadge).toHaveCount(0);
|
||||
await expect(futureBookingsBadge).toHaveCount(0);
|
||||
expect(requestedDepartments).toContain("1");
|
||||
|
||||
await currentBookingsBadge.hover();
|
||||
await expect(bookingsTooltip).toBeVisible();
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-overdue")).toContainText("2");
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-future")).toContainText("1");
|
||||
|
||||
await page.goto("/admin/2/modules/daily-report");
|
||||
|
||||
@@ -583,4 +528,63 @@ test.describe("Admin department visibility", () => {
|
||||
await bookingsLabel.click();
|
||||
await expect(page).toHaveURL(/\/admin\/2\/modules\/bookings$/);
|
||||
});
|
||||
|
||||
test("uses the cached bookings counts endpoint for desktop buefy menu badges", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
const countRequests: string[] = [];
|
||||
const listRequests: string[] = [];
|
||||
|
||||
await mockAdminDepartmentDependencies(page, {
|
||||
permissions: [
|
||||
...adminPermissions,
|
||||
"list_bookings",
|
||||
],
|
||||
});
|
||||
|
||||
await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
countRequests.push(url.searchParams.get("department") || "");
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
past: 1,
|
||||
current: 2,
|
||||
future: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(/\/order-bookings(\?.*)?$/i, async (route) => {
|
||||
listRequests.push(route.request().url());
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: [],
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.goto("/admin/1/modules/daily-report");
|
||||
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-badge-overdue")).toHaveCount(0);
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-badge")).toHaveText("2");
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-badge-future")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("desktop-buefy-nav-bookings-badge").hover();
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-overdue")).toContainText("1");
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-future")).toContainText("3");
|
||||
|
||||
expect(countRequests).toContain("1");
|
||||
expect(listRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -432,6 +432,33 @@ function createDraftTransactionPosFixture() {
|
||||
});
|
||||
}
|
||||
|
||||
function createOpenStripeInvoiceOrderFixture() {
|
||||
const baseFixture = createPosFixture();
|
||||
|
||||
return createPosFixture({
|
||||
ordersById: {
|
||||
54518: {
|
||||
...baseFixture.ordersById[54518],
|
||||
customer_id: 999,
|
||||
invoice_collection_id: 200,
|
||||
},
|
||||
},
|
||||
stripeModuleOrdersByOrderId: {
|
||||
54518: {
|
||||
id: 54518,
|
||||
invoice_id: "in_54518_open",
|
||||
customer_id: 999,
|
||||
url: "https://stripe.example.test/invoices/in_54518_open",
|
||||
created_at: "2026-04-21 13:09:03",
|
||||
paid: false,
|
||||
status: "open",
|
||||
amount_due: 1695,
|
||||
amount_paid: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Admin POS Orders - desktop settings", () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order settings coverage");
|
||||
@@ -1166,6 +1193,51 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
await expect(getOrderDetailInput(page, "booking_id")).toHaveValue(expectedValues.booking_id);
|
||||
});
|
||||
|
||||
test("opens linked order bookings from the details tab using the order-booking route", async ({ page }) => {
|
||||
const posFixture = createPosFixture();
|
||||
posFixture.ordersById[54518] = {
|
||||
...posFixture.ordersById[54518],
|
||||
booking_id: 7001,
|
||||
};
|
||||
posFixture.orderBookings = [
|
||||
{
|
||||
id: 7001,
|
||||
customer_number: 12345679,
|
||||
customer_name: "(TEST) Pleno Vognmandsforretning",
|
||||
department: 12,
|
||||
datetime: "2026-04-08 10:15:00",
|
||||
date: "2026-04-08",
|
||||
reg_1: "EC21235",
|
||||
reg_2: "",
|
||||
reg_3: "",
|
||||
reference: "EC21233 - Test Ref. / Intern nummer",
|
||||
notes: "",
|
||||
po: "",
|
||||
pickup: false,
|
||||
items: [],
|
||||
order_id: 54518,
|
||||
},
|
||||
];
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: posFixture,
|
||||
});
|
||||
|
||||
await openOrderDetail(page);
|
||||
await openOrderDetailsTab(page);
|
||||
|
||||
const linkedBooking = page.getByTestId("pos-order-detail-booking_id").locator(".is-clickable").first();
|
||||
await expect(linkedBooking).toBeVisible();
|
||||
|
||||
await linkedBooking.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/12\/modules\/bookings\/order\/7001$/);
|
||||
await expect(page.locator("h1").first()).toContainText("#7001");
|
||||
});
|
||||
|
||||
test("persists department, created_at, and invoice inclusion changes through per-field modals", async ({ page }) => {
|
||||
const { orderId } = await createDisposableOrder(page);
|
||||
const updatedCreatedAt = "2026-04-09T13:37";
|
||||
@@ -1388,6 +1460,44 @@ test("shows only the draft export and receipt actions in the order rail for stan
|
||||
await expect(receiptPopup.locator("body")).not.toContainText("Download Excel");
|
||||
});
|
||||
|
||||
test("shows live Stripe payment status and cancellation for open card payment orders", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only Stripe order rail coverage");
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: createOpenStripeInvoiceOrderFixture(),
|
||||
});
|
||||
await primeOperatorSession(page, "pos-orders-open-stripe-order-token");
|
||||
|
||||
await openOrderDetail(page);
|
||||
|
||||
const stripePanel = page.getByTestId("pos-order-stripe-email-panel");
|
||||
const deleteStripeInvoiceRequest = page.waitForRequest((request) => {
|
||||
if (request.method() !== "DELETE" || !request.url().includes("/modules/stripe/invoice")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = (request.postDataJSON?.() || {}) as Record<string, unknown>;
|
||||
return Number(body.order_id) === 54518;
|
||||
});
|
||||
|
||||
await expect(stripePanel).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-stripe-email-status")).toHaveText(/Afventer betaling|Awaiting payment/);
|
||||
await expect(page.getByTestId("pos-order-stripe-email-link")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-stripe-email-cancel")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-print-receipt")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Fuldfør ordre|Complete order/ })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Hent faktura|Get invoice/ })).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("pos-order-stripe-email-cancel").click();
|
||||
await deleteStripeInvoiceRequest;
|
||||
|
||||
await expect(page.getByTestId("pos-order-stripe-email-panel")).toHaveCount(0);
|
||||
await expect(page.getByTestId("economic-invoice-export-flow")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("Admin POS Orders - draft transaction customer", () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only draft customer coverage");
|
||||
|
||||
@@ -53,28 +53,15 @@ test.describe("Superuser bookings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/order-bookings**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const filters = url.searchParams.get("filters") || "";
|
||||
await page.route("https://api.truckwash.io:4433/order-bookings/counts**", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data:
|
||||
filters === ""
|
||||
? [
|
||||
{ id: 8801, department: 1, order_id: "", customer_name: "Nord Transport", reg_1: "AB12345", datetime: "2026-04-21 09:00:00", items: [] },
|
||||
{ id: 8802, department: 2, order_id: "", customer_name: "Vest Logistik", reg_1: "CD67890", datetime: "2026-04-21 11:00:00", items: [] },
|
||||
{ id: 8803, department: 2, order_id: "", customer_name: "Syd Cargo", reg_1: "EF24680", datetime: "2026-04-21 13:00:00", items: [] },
|
||||
]
|
||||
: [],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: 0,
|
||||
},
|
||||
data: {
|
||||
past: 0,
|
||||
current: 3,
|
||||
future: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -154,13 +141,24 @@ test.describe("Superuser bookings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/order-bookings**", async (route) => {
|
||||
await page.route("https://api.truckwash.io:4433/order-bookings/counts**", async (route) => {
|
||||
orderBookingFilters.push("counts");
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
past: 0,
|
||||
current: 3,
|
||||
future: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(/^https:\/\/api\.truckwash\.io:4433\/order-bookings(?:\?.*)?$/i, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const filters = url.searchParams.get("filters") || "";
|
||||
const limit = Number(url.searchParams.get("limit") || "100");
|
||||
orderBookingFilters.push(filters);
|
||||
|
||||
const isNavigationCountRequest = filters === "";
|
||||
const isPendingTodayRequest =
|
||||
filters.includes("datetime-date_from:") && filters.includes("datetime-date_to:");
|
||||
const isDepartmentScoped = filters.includes("department:");
|
||||
@@ -169,7 +167,7 @@ test.describe("Superuser bookings", () => {
|
||||
json({
|
||||
success: true,
|
||||
data:
|
||||
(isNavigationCountRequest || isPendingTodayRequest) && !isDepartmentScoped && limit > 1
|
||||
isPendingTodayRequest && !isDepartmentScoped && limit > 1
|
||||
? [
|
||||
{
|
||||
id: 8701,
|
||||
@@ -216,7 +214,7 @@ test.describe("Superuser bookings", () => {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: limit,
|
||||
total: (isNavigationCountRequest || isPendingTodayRequest) && !isDepartmentScoped ? 1 : 0,
|
||||
total: isPendingTodayRequest && !isDepartmentScoped ? 1 : 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -235,6 +233,8 @@ test.describe("Superuser bookings", () => {
|
||||
|
||||
await expect(bookingsLabel).toContainText("Bookinger");
|
||||
await expect(bookingsBadge).toHaveText("3");
|
||||
await bookingsBadge.hover();
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-badge-tooltip")).toContainText("I dag");
|
||||
|
||||
await bookingsLabel.click();
|
||||
|
||||
@@ -247,10 +247,11 @@ test.describe("Superuser bookings", () => {
|
||||
await expect(page.locator("body")).toContainText("AB12345");
|
||||
await expect(page.locator("body")).toContainText("CD67890");
|
||||
|
||||
expect(orderBookingFilters.filter((filters) => filters.includes("department:"))).toEqual([]);
|
||||
expect(orderBookingFilters.length).toBeGreaterThan(0);
|
||||
expect(orderBookingFilters.every((filter) => filter === "counts")).toBe(true);
|
||||
});
|
||||
|
||||
test("shows superuser past, current, and future bookings badges across all departments", async ({
|
||||
test("shows only the current superuser bookings badge inline and exposes past and future in a tooltip", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
@@ -294,32 +295,13 @@ test.describe("Superuser bookings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/order-bookings**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const filters = url.searchParams.get("filters") || "";
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/order-bookings/counts**", async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data:
|
||||
filters === ""
|
||||
? [
|
||||
{ id: 9701, department: 1, order_id: "", datetime: "2026-04-20 09:00:00" },
|
||||
{ id: 9702, department: 2, order_id: "", datetime: "2026-04-20 10:00:00" },
|
||||
{ id: 9703, department: 1, order_id: "", datetime: "2026-04-21 09:00:00" },
|
||||
{ id: 9704, department: 2, order_id: "", datetime: "2026-04-21 10:00:00" },
|
||||
{ id: 9705, department: 2, order_id: "", datetime: "2026-04-21 11:00:00" },
|
||||
{ id: 9706, department: 1, order_id: "", datetime: "2026-04-21 12:00:00" },
|
||||
{ id: 9707, department: 1, order_id: "", datetime: "2026-04-22 00:00:00" },
|
||||
{ id: 9708, department: 2, order_id: 1234, datetime: "2026-04-22 09:00:00" },
|
||||
]
|
||||
: [],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: Number(url.searchParams.get("limit") || "100"),
|
||||
total: 1,
|
||||
},
|
||||
data: {
|
||||
past: 2,
|
||||
current: 4,
|
||||
future: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -331,10 +313,107 @@ test.describe("Superuser bookings", () => {
|
||||
const overdueBookingsBadge = page.getByTestId("desktop-buefy-nav-bookings-badge-overdue");
|
||||
const currentBookingsBadge = page.getByTestId("desktop-buefy-nav-bookings-badge");
|
||||
const futureBookingsBadge = page.getByTestId("desktop-buefy-nav-bookings-badge-future");
|
||||
const bookingsTooltip = page.getByTestId("desktop-buefy-nav-bookings-tooltip");
|
||||
|
||||
await expect(bookingsLabel).toContainText("Bookinger");
|
||||
await expect(overdueBookingsBadge).toHaveText("2");
|
||||
await expect(currentBookingsBadge).toHaveText("4");
|
||||
await expect(futureBookingsBadge).toHaveText("1");
|
||||
await expect(overdueBookingsBadge).toHaveCount(0);
|
||||
await expect(futureBookingsBadge).toHaveCount(0);
|
||||
|
||||
await currentBookingsBadge.hover();
|
||||
await expect(bookingsTooltip).toBeVisible();
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-overdue")).toContainText("2");
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-future")).toContainText("1");
|
||||
});
|
||||
|
||||
test("uses the cached bookings counts endpoint for superuser menu badges", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
const countRequests: string[] = [];
|
||||
const listRequests: string[] = [];
|
||||
|
||||
await seedAuthenticatedState(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: 6001,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/departments**", async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{ id: 1, name: "Hvidovre", visible: true },
|
||||
{ id: 2, name: "Glostrup", visible: true },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/orders**", async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/order-bookings/counts**", async (route) => {
|
||||
countRequests.push(route.request().url());
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
past: 1,
|
||||
current: 2,
|
||||
future: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(/^https:\/\/api\.truckwash\.io:4433\/order-bookings(?:\?.*)?$/i, async (route) => {
|
||||
listRequests.push(route.request().url());
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: [],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.goto("/superuser/orders");
|
||||
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-badge-overdue")).toHaveCount(0);
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-badge")).toHaveText("2");
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-badge-future")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("desktop-buefy-nav-bookings-badge").hover();
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-overdue")).toContainText("1");
|
||||
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-future")).toContainText("3");
|
||||
|
||||
expect(countRequests.length).toBeGreaterThan(0);
|
||||
expect(listRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,6 +222,8 @@ test.describe("Superuser drafts", () => {
|
||||
|
||||
await expect(draftsLabel).toContainText("Kladder");
|
||||
await expect(draftsBadge).toHaveText("2");
|
||||
await draftsBadge.hover();
|
||||
await expect(page.getByTestId("desktop-buefy-nav-drafts-badge-tooltip")).toContainText("Kladder");
|
||||
|
||||
await draftsLabel.click();
|
||||
|
||||
|
||||
@@ -24,23 +24,13 @@ describe("fetchDepartmentOrderBookingCounts", () => {
|
||||
});
|
||||
|
||||
it("returns past, current, and future pending booking totals for the department", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
authenticatedRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, department: 12, order_id: null, datetime: "2026-04-20 10:00:00" },
|
||||
{ id: 2, department: 12, order_id: null, datetime: "2026-04-20 11:00:00" },
|
||||
{ id: 3, department: 12, order_id: null, datetime: "2026-04-20 12:00:00" },
|
||||
{ id: 4, department: 12, order_id: null, datetime: "2026-04-21 08:00:00" },
|
||||
{ id: 5, department: 12, order_id: null, datetime: "2026-04-21 09:00:00" },
|
||||
{ id: 6, department: 12, order_id: null, datetime: "2026-04-21 10:00:00" },
|
||||
{ id: 7, department: 12, order_id: null, datetime: "2026-04-21 11:00:00" },
|
||||
{ id: 8, department: 12, order_id: null, datetime: "2026-04-22 08:00:00" },
|
||||
{ id: 9, department: 12, order_id: null, datetime: "2026-04-22 09:00:00" },
|
||||
{ id: 10, department: 99, order_id: null, datetime: "2026-04-21 15:00:00" },
|
||||
],
|
||||
data: {
|
||||
past: 3,
|
||||
current: 4,
|
||||
future: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -54,13 +44,8 @@ describe("fetchDepartmentOrderBookingCounts", () => {
|
||||
future: 2,
|
||||
});
|
||||
|
||||
expect(authenticatedRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(authenticatedRequestMock).toHaveBeenCalledWith("/order-bookings", "GET", {
|
||||
filters: "department:12",
|
||||
page: 1,
|
||||
limit: 100,
|
||||
search: "",
|
||||
order: "datetime:DESC",
|
||||
expect(authenticatedRequestMock).toHaveBeenCalledWith("/order-bookings/counts", "GET", {
|
||||
department: 12,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,53 +69,16 @@ describe("fetchDepartmentOrderBookingCounts", () => {
|
||||
expect(authenticatedRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts actual booking rows across pages when pagination metadata under-reports grouped totals", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
const firstPageBookings = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
department: 12,
|
||||
order_id: null,
|
||||
datetime: index < 40 ? "2026-04-20 09:00:00" : index < 75 ? "2026-04-21 09:00:00" : "2026-04-22 09:00:00",
|
||||
}));
|
||||
|
||||
authenticatedRequestMock
|
||||
.mockResolvedValueOnce({
|
||||
it("normalizes the cached endpoint response into booking counts", async () => {
|
||||
authenticatedRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
data: firstPageBookings,
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
past: "41",
|
||||
current: 37,
|
||||
future: "26",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 101, department: 12, order_id: null, datetime: "2026-04-20 10:00:00" },
|
||||
{ id: 102, department: 12, order_id: null, datetime: "2026-04-21 10:00:00" },
|
||||
{ id: 103, department: 12, order_id: null, datetime: "2026-04-21 11:00:00" },
|
||||
{ id: 104, department: 12, order_id: null, datetime: "2026-04-22 10:00:00" },
|
||||
],
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ id: 6, order_id: null, datetime: "2026-04-22 09:00:00" }],
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchDepartmentOrderBookingCounts({
|
||||
@@ -143,44 +91,12 @@ describe("fetchDepartmentOrderBookingCounts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("counts bookings with empty order ids as pending to match the table behavior", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
authenticatedRequestMock
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, department: 12, order_id: "", datetime: "2026-04-20 09:00:00" },
|
||||
{ id: 2, department: 12, order_id: 55, datetime: "2026-04-20 10:00:00" },
|
||||
{ id: 3, department: 12, order_id: "0", datetime: "2026-04-20 11:00:00" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchDepartmentOrderBookingCounts({
|
||||
departmentId: 12,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
past: 2,
|
||||
current: 0,
|
||||
future: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses local day boundaries so past, current, and future buckets split at midnight", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
it("treats missing cached count fields as zero", async () => {
|
||||
authenticatedRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, department: 12, order_id: null, datetime: "2026-04-20 23:59:59" },
|
||||
{ id: 2, department: 12, order_id: null, datetime: "2026-04-21 00:00:00" },
|
||||
{ id: 3, department: 12, order_id: null, datetime: "2026-04-21 23:59:59" },
|
||||
{ id: 4, department: 12, order_id: null, datetime: "2026-04-22 00:00:00" },
|
||||
],
|
||||
data: {
|
||||
current: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -189,9 +105,9 @@ describe("fetchDepartmentOrderBookingCounts", () => {
|
||||
departmentId: 12,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
past: 1,
|
||||
current: 2,
|
||||
future: 1,
|
||||
past: 0,
|
||||
current: 5,
|
||||
future: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,20 +134,13 @@ describe("fetchDepartmentOrderBookingCounts", () => {
|
||||
});
|
||||
|
||||
it("returns the current booking count from the grouped counts helper", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
authenticatedRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, department: 12, order_id: null, datetime: "2026-04-20 10:00:00" },
|
||||
{ id: 2, department: 12, order_id: null, datetime: "2026-04-21 10:00:00" },
|
||||
{ id: 3, department: 12, order_id: null, datetime: "2026-04-21 11:00:00" },
|
||||
{ id: 4, department: 12, order_id: null, datetime: "2026-04-21 12:00:00" },
|
||||
{ id: 5, department: 12, order_id: null, datetime: "2026-04-21 13:00:00" },
|
||||
{ id: 6, department: 12, order_id: null, datetime: "2026-04-21 14:00:00" },
|
||||
{ id: 7, department: 12, order_id: null, datetime: "2026-04-22 10:00:00" },
|
||||
],
|
||||
data: {
|
||||
past: 1,
|
||||
current: 5,
|
||||
future: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -24,20 +24,13 @@ describe("fetchSuperUserBookingCounts", () => {
|
||||
});
|
||||
|
||||
it("returns past, current, and future pending booking totals across departments", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
authenticatedRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, department: 1, order_id: null, datetime: "2026-04-20 10:00:00" },
|
||||
{ id: 2, department: 2, order_id: null, datetime: "2026-04-20 11:00:00" },
|
||||
{ id: 3, department: 1, order_id: null, datetime: "2026-04-21 08:00:00" },
|
||||
{ id: 4, department: 2, order_id: null, datetime: "2026-04-21 09:00:00" },
|
||||
{ id: 5, department: 1, order_id: null, datetime: "2026-04-22 08:00:00" },
|
||||
{ id: 6, department: 2, order_id: null, datetime: "2026-04-22 09:00:00" },
|
||||
{ id: 7, department: 2, order_id: 99, datetime: "2026-04-21 12:00:00" },
|
||||
],
|
||||
data: {
|
||||
past: 2,
|
||||
current: 2,
|
||||
future: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,50 +40,19 @@ describe("fetchSuperUserBookingCounts", () => {
|
||||
future: 2,
|
||||
});
|
||||
|
||||
expect(authenticatedRequestMock).toHaveBeenCalledWith("/order-bookings", "GET", {
|
||||
filters: "",
|
||||
page: 1,
|
||||
limit: 100,
|
||||
search: "",
|
||||
order: "datetime:DESC",
|
||||
});
|
||||
expect(authenticatedRequestMock).toHaveBeenCalledWith("/order-bookings/counts", "GET", {});
|
||||
});
|
||||
|
||||
it("counts actual booking rows across pages when pagination metadata under-reports grouped totals", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
const firstPageBookings = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
order_id: null,
|
||||
datetime: index < 30 ? "2026-04-20 09:00:00" : index < 80 ? "2026-04-21 09:00:00" : "2026-04-22 09:00:00",
|
||||
}));
|
||||
|
||||
authenticatedRequestMock
|
||||
.mockResolvedValueOnce({
|
||||
it("normalizes the cached endpoint response into grouped counts", async () => {
|
||||
authenticatedRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
data: firstPageBookings,
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
past: "31",
|
||||
current: 51,
|
||||
future: "21",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 101, order_id: null, datetime: "2026-04-20 10:00:00" },
|
||||
{ id: 102, order_id: null, datetime: "2026-04-21 10:00:00" },
|
||||
{ id: 103, order_id: null, datetime: "2026-04-22 10:00:00" },
|
||||
],
|
||||
meta: {
|
||||
pagination: {
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fetchSuperUserBookingCounts()).resolves.toEqual({
|
||||
past: 31,
|
||||
@@ -99,47 +61,19 @@ describe("fetchSuperUserBookingCounts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats empty order ids as pending bookings", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
it("treats missing cached count fields as zero", async () => {
|
||||
authenticatedRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, order_id: "", datetime: "2026-04-20 09:00:00" },
|
||||
{ id: 2, order_id: "0", datetime: "2026-04-21 09:00:00" },
|
||||
{ id: 3, order_id: null, datetime: "2026-04-22 09:00:00" },
|
||||
{ id: 4, order_id: 17, datetime: "2026-04-21 10:00:00" },
|
||||
],
|
||||
data: {
|
||||
current: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fetchSuperUserBookingCounts()).resolves.toEqual({
|
||||
past: 1,
|
||||
past: 0,
|
||||
current: 1,
|
||||
future: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses local day boundaries so past, current, and future buckets split at midnight", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
authenticatedRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, order_id: null, datetime: "2026-04-20 23:59:59" },
|
||||
{ id: 2, order_id: null, datetime: "2026-04-21 00:00:00" },
|
||||
{ id: 3, order_id: null, datetime: "2026-04-21 23:59:59" },
|
||||
{ id: 4, order_id: null, datetime: "2026-04-22 00:00:00" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fetchSuperUserBookingCounts()).resolves.toEqual({
|
||||
past: 1,
|
||||
current: 2,
|
||||
future: 1,
|
||||
future: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,18 +91,13 @@ describe("fetchSuperUserBookingCounts", () => {
|
||||
});
|
||||
|
||||
it("returns the current booking count from the grouped counts helper", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 21, 12, 34, 56));
|
||||
|
||||
authenticatedRequestMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, order_id: null, datetime: "2026-04-20 10:00:00" },
|
||||
{ id: 2, order_id: null, datetime: "2026-04-21 10:00:00" },
|
||||
{ id: 3, order_id: null, datetime: "2026-04-21 11:00:00" },
|
||||
{ id: 4, order_id: null, datetime: "2026-04-21 12:00:00" },
|
||||
{ id: 5, order_id: null, datetime: "2026-04-22 10:00:00" },
|
||||
],
|
||||
data: {
|
||||
past: 1,
|
||||
current: 3,
|
||||
future: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user