Files
pleno-vue/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupSelectOrderBooking.vue
T

784 lines
22 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import {
getOrderBookingItemPreviewRows,
getOrderBookingItemPreviewTotal,
getOrderBookingNotesValue,
getOrderBookingPlateText,
getOrderBookingPoValue,
getOrderBookingReferenceValue,
getOrderBookingServiceLabels,
hasOrderBookingDisplayDetails,
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
type BookingRefreshResult = any[] | { bookings?: any[] } | void;
type OrderBookingPopupProps = {
bookings?: any[];
onSelect?: (booking: any) => Promise<void> | void;
onSkip?: () => Promise<void> | void;
onRefreshBookings?: () => Promise<BookingRefreshResult> | BookingRefreshResult;
departmentId?: number | string | null;
matchedPlate?: string;
};
const { t, locale } = useI18n();
const popupProps = computed<OrderBookingPopupProps>(() => {
return (popups.get()?.props || {}) as OrderBookingPopupProps;
});
const bookingsFromProps = computed(() => (Array.isArray(popupProps.value.bookings) ? popupProps.value.bookings : []));
const bookings = ref<any[]>([]);
const detailedBookingsById = ref<Record<number, any>>({});
const loadingBookingIds = ref<number[]>([]);
const failedBookingIds = ref<number[]>([]);
const isRefreshingBookings = ref(false);
const toPositiveInteger = (value: any) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const normalizePlateValue = (value: any) =>
String(value ?? "")
.trim()
.toUpperCase();
const formatBookingDateTime = (booking: any) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return t("admin.pos.not_found");
}
const parsedValue = new Date(rawValue);
if (Number.isNaN(parsedValue.getTime())) {
return rawValue;
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(parsedValue);
};
const getBookingDateValue = (booking: any) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return null;
}
const parsedValue = new Date(rawValue);
return Number.isNaN(parsedValue.getTime()) ? null : parsedValue;
};
const isSameCalendarDay = (left: Date | null, right: Date | null) => {
if (!(left instanceof Date) || !(right instanceof Date)) {
return false;
}
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
};
const getDetailedBooking = (booking: any) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? detailedBookingsById.value[bookingId] || null : null;
};
const getDisplayBooking = (booking: any) => {
const detailedBooking = getDetailedBooking(booking);
return detailedBooking ? { ...booking, ...detailedBooking } : booking;
};
const isBookingLoading = (booking: any) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? loadingBookingIds.value.includes(bookingId) : false;
};
const didBookingDetailFail = (booking: any) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? failedBookingIds.value.includes(bookingId) : false;
};
const getBookingServiceLabels = (booking: any) => getOrderBookingServiceLabels(getDisplayBooking(booking));
const getBookingItemPreviewRows = (booking: any) => getOrderBookingItemPreviewRows(getDisplayBooking(booking));
const getBookingItemPreviewTotal = (booking: any) => getOrderBookingItemPreviewTotal(getDisplayBooking(booking));
const getBookingNote = (booking: any) => getOrderBookingNotesValue(getDisplayBooking(booking));
const getBookingReference = (booking: any) => getOrderBookingReferenceValue(getDisplayBooking(booking));
const getBookingPo = (booking: any) => getOrderBookingPoValue(getDisplayBooking(booking));
const getBookingPlates = (booking: any) => getOrderBookingPlateText(getDisplayBooking(booking));
const formatCurrencyAmount = (value: any) => {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) {
return "-";
}
return SessionUser.functions.currency.toLocal(parsedValue);
};
const totalAmountLabel = computed(() => SessionUser.objects.orders.columns.total_net_amount?.label || "Total");
const resolvedBookingCount = computed(() => {
return bookings.value.filter((booking) => {
const displayBooking = getDisplayBooking(booking);
return hasOrderBookingDisplayDetails(displayBooking) || !isBookingLoading(booking) || didBookingDetailFail(booking);
}).length;
});
const isLoadingAnyBookingDetails = computed(() => loadingBookingIds.value.length > 0);
const isAnyBookingFetchInProgress = computed(() => isLoadingAnyBookingDetails.value || isRefreshingBookings.value);
const sortedBookings = computed(() => {
const now = new Date();
return bookings.value
.map((booking, index) => {
const displayBooking = getDisplayBooking(booking);
const bookingDate = getBookingDateValue(displayBooking);
const isToday = isSameCalendarDay(bookingDate, now);
return {
booking,
index,
bookingDate,
isToday,
};
})
.sort((left, right) => {
if (left.isToday !== right.isToday) {
return left.isToday ? -1 : 1;
}
if (left.bookingDate && right.bookingDate) {
const timeDifference = left.bookingDate.getTime() - right.bookingDate.getTime();
if (timeDifference !== 0) {
return timeDifference;
}
} else if (left.bookingDate || right.bookingDate) {
return left.bookingDate ? -1 : 1;
}
return left.index - right.index;
})
.map((entry) => entry.booking);
});
const fetchBookingDetails = async (bookingsToInspect: any[]) => {
const pendingBookings = (Array.isArray(bookingsToInspect) ? bookingsToInspect : []).filter(Boolean);
const bookingsToLoad = pendingBookings.filter((booking) => {
const bookingId = toPositiveInteger(booking?.id);
if (!bookingId) {
return false;
}
if (detailedBookingsById.value[bookingId]) {
return false;
}
if (loadingBookingIds.value.includes(bookingId)) {
return false;
}
return true;
});
if (bookingsToLoad.length === 0) {
return;
}
const bookingIdsToLoad = bookingsToLoad
.map((booking) => toPositiveInteger(booking?.id))
.filter((bookingId): bookingId is number => bookingId !== null);
loadingBookingIds.value = [...new Set([...loadingBookingIds.value, ...bookingIdsToLoad])];
const bookingResults = await Promise.allSettled(
bookingsToLoad.map((booking) =>
SessionUser.objects.order_bookings.get.single(toPositiveInteger(booking?.id), {
department_id: popupProps.value.departmentId ?? null,
})
)
);
const nextDetailedBookings = {
...detailedBookingsById.value,
};
const nextFailedBookingIds = new Set(failedBookingIds.value);
bookingsToLoad.forEach((booking, index) => {
const bookingId = bookingIdsToLoad[index];
const result = bookingResults[index];
if (result.status === "fulfilled" && result.value?.id) {
nextDetailedBookings[result.value.id] = result.value;
nextFailedBookingIds.delete(result.value.id);
return;
}
if (bookingId) {
nextFailedBookingIds.add(bookingId);
}
});
detailedBookingsById.value = nextDetailedBookings;
failedBookingIds.value = Array.from(nextFailedBookingIds);
loadingBookingIds.value = loadingBookingIds.value.filter((bookingId) => !bookingIdsToLoad.includes(bookingId));
};
watch(
bookingsFromProps,
(nextBookings) => {
bookings.value = Array.isArray(nextBookings) ? [...nextBookings] : [];
},
{ immediate: true }
);
watch(
() => bookings.value.map((booking) => booking?.id).join("|"),
() => {
void fetchBookingDetails(bookings.value);
},
{ immediate: true }
);
const onSelect = async (booking: any) => {
if (typeof popupProps.value.onSelect === "function") {
await popupProps.value.onSelect(getDisplayBooking(booking));
}
};
const emitRefreshBookings = async () => {
if (typeof popupProps.value.onRefreshBookings !== "function") {
return;
}
isRefreshingBookings.value = true;
try {
const refreshResult = await popupProps.value.onRefreshBookings();
if (Array.isArray(refreshResult)) {
bookings.value = refreshResult;
return;
}
if (Array.isArray(refreshResult?.bookings)) {
bookings.value = refreshResult.bookings;
}
} finally {
isRefreshingBookings.value = false;
}
};
const getActionBooking = (booking: any) => getDisplayBooking(booking);
const getActionCustomerNumber = (booking: any) => {
const displayBooking = getActionBooking(booking);
return toPositiveInteger(displayBooking?.customer_number ?? displayBooking?.customer_id);
};
const getActionDepartmentId = (booking: any) => {
const displayBooking = getActionBooking(booking);
return toPositiveInteger(displayBooking?.department ?? popupProps.value.departmentId);
};
const isBookingScheduledForToday = (booking: any) => {
return isSameCalendarDay(getBookingDateValue(getDisplayBooking(booking)), new Date());
};
</script>
<template>
<div class="booking-popup" data-testid="pos-mobile-order-booking-popup">
<p class="booking-popup__intro">
{{ popups.get()?.message || t("admin.pos.order_booking_selector.help_text") }}
</p>
<div
v-if="isAnyBookingFetchInProgress"
class="booking-popup__loading"
data-testid="pos-mobile-order-booking-loading"
>
<span class="icon is-small">
<i class="fas fa-spinner fa-spin" aria-hidden="true" />
</span>
<span>
{{ SessionUser.objects.global.language.loading }}
{{ resolvedBookingCount }} / {{ bookings.length }}
</span>
</div>
<div class="booking-popup__list" data-testid="pos-mobile-order-booking-list">
<article
v-for="booking in sortedBookings"
:key="booking.id"
class="booking-option"
:class="{ 'booking-option--today': isBookingScheduledForToday(booking) }"
:data-testid="`pos-mobile-order-booking-option-${booking.id}`"
>
<div class="booking-option__header">
<div class="booking-option__header-main">
<p class="booking-option__eyebrow">Booking #{{ booking.id }}</p>
<h3 class="booking-option__title">{{ formatBookingDateTime(getDisplayBooking(booking)) }}</h3>
</div>
<div class="booking-option__header-actions">
<span
v-if="isBookingScheduledForToday(booking)"
class="tag booking-option__today-tag"
:data-testid="`pos-mobile-order-booking-today-${booking.id}`"
>
{{ t("common.today") }}
</span>
<span v-if="didBookingDetailFail(booking)" class="tag is-light booking-option__tag">
{{ t("admin.pos.not_found") }}
</span>
<div class="booking-option__settings" :data-testid="`pos-mobile-order-booking-settings-${booking.id}`">
<ActionSettingsWheelButton
:order_booking_id="toPositiveInteger(getActionBooking(booking)?.id)"
:order_id="toPositiveInteger(getActionBooking(booking)?.order_id)"
:customer_number="getActionCustomerNumber(booking)"
:department_id="getActionDepartmentId(booking)"
:reg_1="getActionBooking(booking)?.reg_1 ?? null"
:reg_2="getActionBooking(booking)?.reg_2 ?? null"
:refreshFunction="emitRefreshBookings"
:icon="'fas fa-ellipsis-v'"
trigger-button-variant="text"
allow-booking-completion
>
<template #actions />
</ActionSettingsWheelButton>
</div>
</div>
</div>
<p class="booking-option__customer">
{{ getDisplayBooking(booking)?.customer_name || t("admin.pos.not_found") }}
</p>
<dl class="booking-option__meta-grid">
<div class="booking-option__meta-item">
<dt>{{ t("admin.pos.order_booking_selector.plates_label") }}</dt>
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
</div>
<div class="booking-option__meta-item">
<dt>{{ t("common.reference") }}</dt>
<dd>{{ getBookingReference(booking) || "-" }}</dd>
</div>
<div v-if="getBookingPo(booking)" class="booking-option__meta-item">
<dt>{{ SessionUser.objects.orders.columns.po.label }}</dt>
<dd>{{ getBookingPo(booking) }}</dd>
</div>
<div v-if="getBookingNote(booking)" class="booking-option__meta-item">
<dt>{{ SessionUser.objects.orders.columns.notes.label }}</dt>
<dd>{{ getBookingNote(booking) }}</dd>
</div>
</dl>
<div
v-if="getBookingItemPreviewRows(booking).length > 0 || getBookingServiceLabels(booking).length > 0 || isBookingLoading(booking)"
class="booking-option__services"
:data-testid="`pos-mobile-order-booking-services-${booking.id}`"
>
<div class="booking-option__section-header">
<p class="booking-option__section-title">{{ t("common.services") }}</p>
<div
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
class="booking-option__section-total"
:data-testid="`pos-mobile-order-booking-service-total-${booking.id}`"
>
<span>{{ totalAmountLabel }}</span>
<strong>{{ formatCurrencyAmount(getBookingItemPreviewTotal(booking)) }}</strong>
</div>
</div>
<div v-if="isBookingLoading(booking)" class="booking-option__item-preview-skeleton">
<div class="booking-option__item-preview-row is-skeleton" />
<div class="booking-option__item-preview-row is-skeleton" />
</div>
<div v-else-if="getBookingItemPreviewRows(booking).length > 0" class="booking-option__item-preview">
<div class="booking-option__item-preview-head">
<span>{{ t("common.quantity") }}</span>
<span>{{ t("common.services") }}</span>
<span>{{ t("admin.pos.price_dkk") }}</span>
</div>
<div
v-for="(item, index) in getBookingItemPreviewRows(booking)"
:key="item.key"
class="booking-option__item-preview-row"
:data-testid="`pos-mobile-order-booking-service-row-${booking.id}-${index}`"
>
<span class="booking-option__item-preview-qty">{{ item.quantity }}x</span>
<div class="booking-option__item-preview-main">
<span
class="booking-option__item-preview-name"
:class="{ 'booking-option__item-preview-name--addon': item.depth > 0 }"
>
{{ item.depth > 0 ? "+ " : "" }}{{ item.label }}
</span>
<span v-if="item.unitPrice !== null && item.quantity > 1" class="booking-option__item-preview-meta">
{{ formatCurrencyAmount(item.unitPrice) }} / stk
</span>
</div>
<span class="booking-option__item-preview-amount">
{{ item.totalPrice !== null ? formatCurrencyAmount(item.totalPrice) : "-" }}
</span>
</div>
</div>
<div v-else class="booking-option__service-list">
<span
v-for="service in getBookingServiceLabels(booking)"
:key="`${booking.id}-${service}`"
class="booking-option__service-pill"
>
{{ service }}
</span>
</div>
</div>
<div class="booking-option__actions">
<button
class="button is-primary is-fullwidth"
type="button"
:class="{ 'is-loading': isBookingLoading(booking) }"
:disabled="isBookingLoading(booking)"
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
>
{{ t("common.select") }}
</button>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.booking-popup {
display: flex;
flex-direction: column;
min-height: 0;
}
.booking-popup__intro {
margin-bottom: 0.75rem;
color: #4c5a6e;
font-size: 0.98rem;
line-height: 1.45;
}
.booking-popup__loading {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.8rem;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(15, 35, 77, 0.08);
color: #234;
font-size: 0.88rem;
font-weight: 600;
}
.booking-popup__list {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.booking-option {
display: flex;
flex-direction: column;
gap: 0.7rem;
border: 1px solid #d7e2f0;
border-radius: 14px;
padding: 0.85rem;
background: #fff;
box-shadow: 0 8px 22px rgba(21, 42, 76, 0.08);
overflow: visible;
}
.booking-option--today {
border-color: #3d7ef0;
background: linear-gradient(180deg, rgba(61, 126, 240, 0.08), rgba(61, 126, 240, 0.02)), #fff;
}
.booking-option__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.65rem;
}
.booking-option__header-main {
min-width: 0;
}
.booking-option__header-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.4rem;
flex-shrink: 0;
}
.booking-option__eyebrow {
margin: 0;
color: #5c6d84;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.booking-option__title {
margin: 0.15rem 0 0;
color: #0f234d;
font-size: 1rem;
font-weight: 800;
line-height: 1.25;
}
.booking-option__today-tag {
border-radius: 999px;
border: 1px solid rgba(61, 126, 240, 0.2);
background: rgba(61, 126, 240, 0.12);
color: #18408b;
font-weight: 700;
}
.booking-option__tag {
color: #49576b;
}
.booking-option__settings :deep(.dropdown-menu) {
z-index: 30;
}
.booking-option__settings :deep(.action-settings-wheel-trigger--text) {
border-radius: 999px;
padding: 0.2rem;
}
.booking-option__customer {
margin: 0;
color: #1b314f;
font-size: 0.95rem;
font-weight: 700;
}
.booking-option__meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin: 0;
}
.booking-option__meta-item dt,
.booking-option__section-title {
margin: 0 0 0.25rem;
color: #6b7c92;
font-size: 0.71rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.booking-option__meta-item dd {
margin: 0;
color: #14243b;
font-size: 0.9rem;
line-height: 1.4;
word-break: break-word;
}
.booking-option__section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
margin-bottom: 0.4rem;
}
.booking-option__section-total {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
color: #173559;
font-size: 0.71rem;
font-weight: 700;
}
.booking-option__section-total strong {
color: #0f234d;
font-size: 0.85rem;
}
.booking-option__item-preview {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.booking-option__item-preview-head,
.booking-option__item-preview-row {
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr) auto;
gap: 0.45rem;
align-items: center;
}
.booking-option__item-preview-head {
padding: 0 0.1rem;
color: #6b7c92;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.booking-option__item-preview-head span:last-child {
text-align: right;
}
.booking-option__item-preview-row {
padding: 0.45rem 0.55rem;
border: 1px solid #e5edf7;
border-radius: 10px;
background: linear-gradient(180deg, #fdfefe 0%, #f7fbff 100%);
}
.booking-option__item-preview-qty {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.55rem;
padding: 0.1rem 0.38rem;
border-radius: 999px;
background: #e9f1fb;
color: #18408b;
font-size: 0.74rem;
font-weight: 800;
}
.booking-option__item-preview-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.booking-option__item-preview-name {
color: #14243b;
font-size: 0.84rem;
font-weight: 700;
line-height: 1.2;
word-break: break-word;
}
.booking-option__item-preview-name--addon {
color: #24456e;
font-weight: 600;
}
.booking-option__item-preview-meta {
color: #6a7890;
font-size: 0.72rem;
font-weight: 600;
}
.booking-option__item-preview-amount {
color: #0f234d;
font-size: 0.84rem;
font-weight: 800;
text-align: right;
white-space: nowrap;
}
.booking-option__item-preview-skeleton {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.booking-option__item-preview-row.is-skeleton {
min-height: 2.7rem;
border-color: transparent;
color: transparent;
background: linear-gradient(90deg, #ebf0f7 0%, #f7f9fc 50%, #ebf0f7 100%);
background-size: 200% 100%;
animation: mobile-booking-selector-pulse 1.2s linear infinite;
}
.booking-option__service-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.booking-option__service-pill {
display: inline-flex;
align-items: center;
min-height: 1.85rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: #eef4fb;
color: #173559;
font-size: 0.82rem;
font-weight: 600;
line-height: 1.2;
}
.booking-option__actions {
margin-top: 0.1rem;
}
.booking-option__actions .button {
border-radius: 10px;
font-weight: 700;
}
@keyframes mobile-booking-selector-pulse {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media screen and (max-width: 420px) {
.booking-option__meta-grid {
grid-template-columns: minmax(0, 1fr);
}
.booking-option__section-header {
align-items: flex-start;
flex-direction: column;
}
.booking-option__item-preview-head,
.booking-option__item-preview-row {
grid-template-columns: 2.3rem minmax(0, 1fr);
}
.booking-option__item-preview-head span:last-child,
.booking-option__item-preview-amount {
grid-column: 2;
text-align: left;
}
}
</style>