Files
pleno-vue/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupSelectCustomer.vue
T
Jeppe Bundgaard 817c174e0e Add unit tests for Orders navigation count refresh and fallback customer caching, update POS mobile accessibility labels:
- Introduced `orders-draft-count-refresh.spec.js` for testing `Orders` navigation hooks on customer changes and order deletions.
- Enhanced `use-draft-transaction-customer.spec.js` with tests for clearing cached fallback customers.
- Updated POS mobile components with accessibility labels and localization for image viewer and customer suggestion buttons.
- Added support for `vehicleCustomerSuggestionsGet` handling in e2e mocks and tests.
2026-04-21 15:20:50 +02:00

562 lines
16 KiB
Vue

<script setup lang="ts">
import { computed, defineEmits, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { PosSearchResult } from "../objects/PosSearchResult.vue";
import CustomerSearchField from "@/components/search/economic/customerSearchField.vue";
import { searchCustomerResults, isSearching } from "@/components/search/economic/customerSearch.vue";
import ControlFieldInputSearchResults
from "@/components/viewport/elements/controls/fields/search/ControlFieldInputSearchResults.vue";
import {
searchAndSelectCustomer,
customer_id,
customer_name,
} from "@/components/shop/POSDepartmentProcess.vue";
import { metadata, vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
import VehicleCustomerSuggestionsPos from "@/components/forms/department/pos/input/vehicleCustomerSuggestionsPos.vue";
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
import { useStripeReaderAvailability } from "@/composables/useStripeReaderAvailability.js";
const { t } = useI18n();
const { draftTransactionCustomerNumber, hasDraftTransactionCustomer } = useDraftTransactionCustomer();
const { isCardPaymentAvailable } = useStripeReaderAvailability();
const emit = defineEmits<{
(e: "close"): void;
}>();
const CUSTOMER_PICKER_MODE_INVOICE = "invoice";
const CUSTOMER_PICKER_MODE_DRAFT = "draft";
const CUSTOMER_PICKER_MODE_CARD = "card";
const activeQuickAction = ref(CUSTOMER_PICKER_MODE_INVOICE);
const localSearchResults = ref<PosSearchResult[]>([]);
const toPositiveInteger = (value: unknown) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const resolveSelectedCustomerId = (value: unknown) => {
if (value && typeof value === "object") {
const candidate = value as Record<string, unknown>;
return toPositiveInteger(candidate.customerId ?? candidate.customerNumber ?? candidate.id ?? null);
}
return toPositiveInteger(value);
};
const getCurrentCustomerNumber = () => toPositiveInteger(customer_id.value);
const getResolvedQuickAction = () => {
const currentCustomerNumber = getCurrentCustomerNumber();
if (currentCustomerNumber === 999) {
return CUSTOMER_PICKER_MODE_CARD;
}
if (
hasDraftTransactionCustomer.value &&
currentCustomerNumber === toPositiveInteger(draftTransactionCustomerNumber.value)
) {
return CUSTOMER_PICKER_MODE_DRAFT;
}
return CUSTOMER_PICKER_MODE_INVOICE;
};
const applySearchResults = (rawResults: any[]) => {
localSearchResults.value = rawResults.map((result) => {
return <PosSearchResult>{
customerId: result.customerNumber,
customerName: result.name,
barred: result?.barred ?? false,
customerStatus: result?.barred ? "card" : "verified",
};
});
};
watch(isSearching, (newValue) => {
if (!newValue) {
applySearchResults(searchCustomerResults.value);
}
});
watch(
[() => customer_id.value, draftTransactionCustomerNumber],
() => {
activeQuickAction.value = getResolvedQuickAction();
},
{ immediate: true }
);
const isCustomerSelected = computed(() => String(customer_name.value ?? "").trim() !== "");
const isInvoiceMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_INVOICE);
const isDraftMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_DRAFT);
const isCardMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_CARD);
const shouldShowSearchInput = computed(() => isInvoiceMode.value);
const formattedSearchResults = computed(() => localSearchResults.value);
const isCardPaymentDisabled = computed(() => !isCardPaymentAvailable.value);
const selectedCustomerNumber = computed(() => getCurrentCustomerNumber());
const selectedCustomerMode = computed(() => {
if (!isCustomerSelected.value) {
return null;
}
return getResolvedQuickAction();
});
const selectedCustomerModeLabel = computed(() => {
if (selectedCustomerMode.value === CUSTOMER_PICKER_MODE_CARD) {
return t("pos.customer_picker.select_card_payment");
}
if (selectedCustomerMode.value === CUSTOMER_PICKER_MODE_DRAFT) {
return t("pos.customer_picker.select_draft_customer");
}
return t("pos.customer_picker.select_customer_invoice");
});
const focusCustomerSearchInput = async () => {
await nextTick();
const input = document.querySelector('[data-testid="pos-mobile-customer-search-input"]');
if (input instanceof HTMLElement) {
input.focus();
}
};
const onClick = async (result: Partial<PosSearchResult>) => {
const selectedCustomerId = resolveSelectedCustomerId(result?.customerId);
if (!selectedCustomerId) {
return;
}
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
await searchAndSelectCustomer(selectedCustomerId);
metadata.setCustomerId(selectedCustomerId);
emit("close");
};
const onClickSuggestion = async (payload: unknown) => {
const selectedCustomerId = resolveSelectedCustomerId(payload);
if (!selectedCustomerId) {
return;
}
await onClick({ customerId: selectedCustomerId });
};
const onSelectCustomerInvoice = async () => {
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
await focusCustomerSearchInput();
};
const onClickDirectCardPayment = async () => {
if (isCardPaymentDisabled.value) {
return;
}
activeQuickAction.value = CUSTOMER_PICKER_MODE_CARD;
const cardCustomerId = 999;
await searchAndSelectCustomer(cardCustomerId);
metadata.setCustomerId(cardCustomerId);
emit("close");
};
const onClickDraftCustomer = async () => {
if (!hasDraftTransactionCustomer.value) {
return;
}
activeQuickAction.value = CUSTOMER_PICKER_MODE_DRAFT;
await searchAndSelectCustomer(draftTransactionCustomerNumber.value);
metadata.setCustomerId(draftTransactionCustomerNumber.value);
emit("close");
};
</script>
<template>
<div class="pos-mobile-customer-popup" data-testid="pos-mobile-customer-popup">
<section
v-if="isCustomerSelected"
class="customer-selection-summary"
data-testid="pos-mobile-selected-customer-summary"
>
<div class="customer-selection-summary__label">
{{ t("pos.customer_picker.selected_customer") }}
</div>
<div class="customer-selection-summary__content">
<span class="icon customer-selection-summary__icon">
<i class="fas fa-user-check"></i>
</span>
<div class="customer-selection-summary__text">
<p class="customer-selection-summary__name">{{ customer_name }}</p>
<p v-if="selectedCustomerNumber" class="customer-selection-summary__number">
#{{ selectedCustomerNumber }}
</p>
</div>
<span class="customer-selection-summary__mode">
{{ selectedCustomerModeLabel }}
</span>
</div>
</section>
<div class="customer-action-divider" data-testid="pos-mobile-customer-action-divider">
<span class="customer-action-divider__line"></span>
<span class="customer-action-divider__label">
{{ t("pos.customer_picker.select_payment_form") }}
</span>
<span class="customer-action-divider__line"></span>
</div>
<div class="customer-quick-actions" data-testid="pos-mobile-customer-action-group">
<button
class="button is-light customer-quick-action customer-quick-action--invoice"
:class="{ 'is-selected': isInvoiceMode }"
type="button"
data-testid="pos-mobile-customer-invoice"
:aria-pressed="isInvoiceMode ? 'true' : 'false'"
@click="onSelectCustomerInvoice"
>
<span class="icon is-small customer-quick-action__icon">
<i class="fas fa-user"></i>
</span>
<span>{{ t("pos.customer_picker.select_customer_invoice") }}</span>
</button>
<button
v-if="hasDraftTransactionCustomer"
class="button is-light customer-quick-action customer-quick-action--draft"
:class="{ 'is-selected': isDraftMode }"
type="button"
data-testid="pos-mobile-draft-customer"
:aria-pressed="isDraftMode ? 'true' : 'false'"
@click="onClickDraftCustomer"
>
<span class="icon is-small customer-quick-action__icon">
<i class="fas fa-file-invoice"></i>
</span>
<span>{{ t("pos.customer_picker.select_draft_customer") }}</span>
</button>
<button
class="button is-light customer-quick-action customer-quick-action--card"
:class="{ 'is-selected': isCardMode, 'is-disabled': isCardPaymentDisabled }"
type="button"
data-testid="pos-mobile-direct-card-payment"
:aria-pressed="isCardMode ? 'true' : 'false'"
:disabled="isCardPaymentDisabled"
@click="onClickDirectCardPayment"
>
<span class="icon is-small customer-quick-action__icon">
<i class="fas fa-credit-card"></i>
</span>
<span>{{ t("pos.customer_picker.select_card_payment") }}</span>
</button>
</div>
<section v-if="shouldShowSearchInput" class="customer-search-panel">
<label class="customer-search-panel__label">{{ t("common.customer") }}</label>
<div class="field customer-search-panel__field">
<div class="control has-icons-right" :class="{ 'is-loading': isSearching }">
<CustomerSearchField testId="pos-mobile-customer-search-input" />
<span class="icon is-right">
<i class="fas fa-search"></i>
</span>
</div>
</div>
<template v-if="formattedSearchResults.length > 0">
<div class="customer-search-results">
<ControlFieldInputSearchResults
v-bind:results="formattedSearchResults"
testIdPrefix="pos-mobile-customer-search-result"
@result-clicked="onClick"
/>
</div>
</template>
</section>
<VehicleCustomerSuggestionsPos
v-if="shouldShowSearchInput && vehicles?.vehicle_1?.value?.reg"
class="customer-suggestions"
:reg_1="vehicles.vehicle_1.value.reg"
@customerSelected="onClickSuggestion"
/>
</div>
</template>
<style scoped>
.pos-mobile-customer-popup {
display: flex;
flex-direction: column;
gap: 1.25rem;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 10px;
}
.customer-selection-summary {
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
border: 1px solid #d7e3f5;
border-radius: 1rem;
box-shadow: 0 14px 28px rgba(17, 46, 92, 0.08);
padding: 0.95rem 1rem;
}
.customer-selection-summary__label {
color: #5a6f8f;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
margin-bottom: 0.55rem;
text-transform: uppercase;
}
.customer-selection-summary__content {
align-items: center;
display: flex;
gap: 0.8rem;
}
.customer-selection-summary__icon {
align-items: center;
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
border-radius: 999px;
color: #ffffff;
display: inline-flex;
flex-shrink: 0;
height: 2.4rem;
justify-content: center;
width: 2.4rem;
}
.customer-selection-summary__text {
flex: 1 1 auto;
min-width: 0;
}
.customer-selection-summary__name {
color: #17325c;
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
margin: 0;
}
.customer-selection-summary__number {
color: #5d7090;
font-size: 0.82rem;
margin: 0.2rem 0 0;
}
.customer-selection-summary__mode {
background: rgba(23, 50, 92, 0.08);
border-radius: 999px;
color: #17325c;
flex-shrink: 0;
font-size: 0.74rem;
font-weight: 700;
line-height: 1.2;
max-width: 10.5rem;
padding: 0.45rem 0.7rem;
text-align: right;
}
.customer-action-divider {
align-items: center;
display: flex;
gap: 0.7rem;
}
.customer-action-divider__line {
background: linear-gradient(
90deg,
rgba(170, 188, 214, 0) 0%,
rgba(170, 188, 214, 0.95) 25%,
rgba(170, 188, 214, 0.95) 75%,
rgba(170, 188, 214, 0) 100%
);
flex: 1 1 auto;
height: 1px;
}
.customer-action-divider__label {
color: #6d7f99;
flex: 0 0 auto;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.customer-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.customer-quick-actions :deep(.button) {
margin: 0;
}
.customer-quick-action {
align-items: center;
border: 1px solid #d2deee;
border-radius: 0.95rem;
box-shadow: 0 8px 16px rgba(21, 49, 93, 0.06);
color: #17325c;
display: inline-flex;
flex: 1 1 10rem;
font-weight: 600;
gap: 0.75rem;
justify-content: center;
max-width: 100%;
min-height: 3.25rem;
padding: 0.9rem 1rem;
text-align: center;
text-decoration: none;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
white-space: normal;
}
.customer-quick-action.is-selected {
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
border-color: #10294b;
box-shadow: 0 14px 24px rgba(15, 39, 72, 0.18);
color: #ffffff;
}
.customer-quick-action:hover,
.customer-quick-action:focus-visible {
background-color: #f1f6ff;
border-color: #b6cae6;
color: #0f2a55;
}
.customer-quick-action.is-selected:hover,
.customer-quick-action.is-selected:focus-visible {
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
border-color: #10294b;
color: #ffffff;
}
.customer-quick-action__icon {
align-items: center;
background: #e9f2ff;
border-radius: 999px;
color: #184b8a;
display: inline-flex;
flex-shrink: 0;
height: 1.95rem;
justify-content: center;
width: 1.95rem;
}
.customer-quick-action.is-selected .customer-quick-action__icon {
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
}
.customer-quick-action:disabled,
.customer-quick-action.is-disabled {
background: #f4f7fb;
border-color: #d6dfec;
box-shadow: none;
color: #90a0b7;
cursor: not-allowed;
opacity: 1;
}
.customer-quick-action:disabled .customer-quick-action__icon,
.customer-quick-action.is-disabled .customer-quick-action__icon {
background: #edf2f8;
color: #8ca0bc;
}
.customer-quick-action:disabled:hover,
.customer-quick-action:disabled:focus-visible,
.customer-quick-action.is-disabled:hover,
.customer-quick-action.is-disabled:focus-visible {
background: #f4f7fb;
border-color: #d6dfec;
color: #90a0b7;
}
.customer-quick-action--invoice .customer-quick-action__icon {
background: #edf5ff;
color: #1b4f92;
}
.customer-quick-action--draft .customer-quick-action__icon {
background: #eef0ff;
color: #324cb1;
}
.customer-quick-action--card .customer-quick-action__icon {
background: #e4f7fa;
color: #0f6a7b;
}
.customer-search-panel {
background: #ffffff;
border: 1px solid #dbe5f2;
border-radius: 1rem;
box-shadow: 0 10px 18px rgba(21, 49, 93, 0.05);
margin-top: 0.15rem;
padding: 0.8rem 0.9rem 0.95rem;
}
.customer-search-panel__label {
color: #17325c;
display: block;
font-size: 0.82rem;
font-weight: 700;
margin-bottom: 0.45rem;
}
.customer-search-panel__field {
border-bottom: 1px solid #c9d6e8;
margin-bottom: 0;
padding-bottom: 0;
}
.customer-search-panel :deep(input) {
align-items: center;
background: #ffffff;
border: none;
box-shadow: none;
display: flex;
gap: 10px;
height: 42px;
padding: 0.65rem 0.25rem 0.5rem;
width: 100%;
}
.customer-search-panel :deep(input:focus-visible) {
outline: none;
}
.customer-search-results {
margin-top: 0.75rem;
}
.customer-suggestions {
margin-top: 0.1rem;
}
@media (max-width: 380px) {
.customer-quick-action {
flex-basis: 100%;
}
.customer-selection-summary__content {
align-items: flex-start;
flex-wrap: wrap;
}
.customer-selection-summary__mode {
max-width: none;
text-align: left;
}
}
</style>