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:
Jeppe Bundgaard
2026-04-21 13:38:26 +02:00
parent 1bd1b992a2
commit 84e3b0e982
13 changed files with 989 additions and 616 deletions
@@ -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;
+104 -100
View File
@@ -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([]);
});
});
+110
View File
@@ -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");
+130 -51
View File
@@ -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([]);
});
});
+2
View File
@@ -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();
+27 -118
View File
@@ -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,
},
},
});
+25 -96
View File
@@ -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,
},
},
});