From dfe8eaa882372adcd43d9e9d60d7473cb7d2b455 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Fri, 13 Mar 2026 00:50:54 +0100 Subject: [PATCH] Refactor global search UI and functionality: - Enhance entity alias resolution with extended mappings and alias normalization. - Add display labels, icons, created timestamps, and cancellation status to result cards. - Update styles to highlight cancelled results and improve visual hierarchy. - Expand i18n support for new labels in multiple locales, including `created`, `status`, and `cancelled`. --- .../menu/NavigationMenuGlobalSearch.vue | 200 +++++++++++++++++- src/i18n/locales/da.json | 4 + src/i18n/locales/de.json | 4 + src/i18n/locales/en.json | 3 + src/i18n/locales/no.json | 4 + src/i18n/locales/sv.json | 4 + 6 files changed, 213 insertions(+), 6 deletions(-) diff --git a/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue b/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue index 3dd7d9b7..0cc7979e 100644 --- a/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue +++ b/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue @@ -2,7 +2,7 @@ import { computed, nextTick, ref, watch } from 'vue'; import { useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import { BButton, BCheckbox, BField, BInput, BModal, BPagination, BProgress, BSelect, BSidebar } from 'buefy'; +import { BButton, BCheckbox, BField, BIcon, BInput, BModal, BPagination, BProgress, BSelect, BSidebar } from 'buefy'; import Swal from 'sweetalert2'; import SessionUser from '@/components/session/token/SessionUser.vue'; import { showEditOrderItemForm } from '@/components/forms/superUser/editOrderItemForm.vue'; @@ -58,7 +58,7 @@ type SearchResponse = { }; const router = useRouter(); -const { t } = useI18n(); +const { t, locale } = useI18n(); const isModalOpen = ref(false); const method = ref('POST'); @@ -262,6 +262,133 @@ const readPayloadValue = (payload: SearchPayload | null, keys: string[]): unknow return null; }; +const OBJECT_SUBTYPE_KEYS = ['object_type', 'objectType', 'entity_type', 'entityType', 'table', 'table_name', 'tableName', 'type']; +const CANCELLED_STATUS_ALIASES = new Set(['cancelled', 'canceled', 'cancel', 'aflyst', 'annulleret']); + +const normalizeEntityAliasForUi = (alias: string): string => { + if (!alias) return ''; + if (['attachment', 'attachments', 'file', 'files', 'document', 'documents'].includes(alias)) return 'attachments'; + if (['order_item', 'order_items', 'item', 'items'].includes(alias)) return 'order_items'; + if (['customer_discount', 'customer_discounts', 'discount', 'discounts'].includes(alias)) return 'customer_discounts'; + if (['customer_fixed_price', 'customer_fixed_prices', 'fixed_price', 'fixed_prices', 'fixed_pricing'].includes(alias)) return 'customer_fixed_prices'; + if (['config_variable', 'config_variables', 'module_config_variable', 'module_config_variables', 'configuration_variable', 'configuration_variables'].includes(alias)) return 'module_config'; + if (['department_variable', 'department_variables', 'variable', 'variables'].includes(alias)) return 'department_variables'; + if (['booking', 'bookings', 'bookings_new', 'order_bookings'].includes(alias)) return 'bookings'; + if (['question', 'questions'].includes(alias)) return 'department_selfserve_questions'; + if (['task', 'tasks'].includes(alias)) return 'department_selfserve_tasks'; + if (['condition', 'conditions'].includes(alias)) return 'department_selfserve_conditions'; + if (['condition_rule', 'condition_rules'].includes(alias)) return 'department_selfserve_condition_rules'; + if (['vehicle_condition', 'vehicle_conditions'].includes(alias)) return 'department_selfserve_vehicle_conditions'; + return alias; +}; + +const resolveSubtypeAlias = (payload: SearchPayload | null): string => toAliasKey(readPayloadValue(payload, OBJECT_SUBTYPE_KEYS)); + +const resolveUiEntityAlias = (row: SearchResult, payload: SearchPayload | null): string => { + const entityAlias = toAliasKey(row.entity_type); + if (entityAlias === 'objects') return normalizeEntityAliasForUi(resolveSubtypeAlias(payload) || entityAlias); + return normalizeEntityAliasForUi(entityAlias); +}; + +const resolveUiTypeLabel = (row: SearchResult, payload: SearchPayload | null): string => { + const entityAlias = toAliasKey(row.entity_type); + if (entityAlias !== 'objects') return humanize(row.entity_type); + const objectAlias = normalizeEntityAliasForUi(resolveSubtypeAlias(payload)); + return objectAlias ? humanize(objectAlias) : humanize(row.entity_type); +}; + +const resolveEntityIconName = (entityAlias: string): string => { + if (entityAlias === 'bookings') return 'calendar-alt'; + if (entityAlias.startsWith('department_time_bookings_')) return 'clock'; + if (entityAlias.startsWith('department_selfserve_')) return 'tasks'; + if (entityAlias === 'orders' || entityAlias === 'order_items') return 'shopping-cart'; + if (entityAlias === 'attachments') return 'paperclip'; + if (entityAlias === 'customer_discounts' || entityAlias === 'customer_fixed_prices') return 'percent'; + if (entityAlias === 'module_config' || entityAlias === 'department_variables') return 'sliders-h'; + if (entityAlias === 'users' || entityAlias === 'customers' || entityAlias === 'subusers' || entityAlias === 'subuser_grants' || entityAlias.includes('customer')) return 'user'; + if (entityAlias.includes('vehicle')) return 'car'; + if (entityAlias === 'invoices') return 'file-invoice'; + if (entityAlias === 'products' || entityAlias === 'product_options' || entityAlias === 'categories' || entityAlias === 'department_categories') return 'box-open'; + if (entityAlias.includes('stripe') || entityAlias.includes('payment')) return 'credit-card'; + if (entityAlias.includes('notification')) return 'bell'; + if (entityAlias === 'departments' || entityAlias.startsWith('department_')) return 'building'; + return 'cube'; +}; + +const toDateValue = (value: unknown): Date | null => { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (typeof value === 'number' && Number.isFinite(value)) { + const asMilliseconds = value > 1_000_000_000_000 ? value : value > 1_000_000_000 ? value * 1000 : NaN; + if (Number.isFinite(asMilliseconds)) { + const parsed = new Date(asMilliseconds); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + if (/^\d+(\.\d+)?$/.test(trimmed)) return toDateValue(Number(trimmed)); + const parsedMillis = Date.parse(trimmed); + if (Number.isNaN(parsedMillis)) return null; + const parsed = new Date(parsedMillis); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; +}; + +const extractCreatedAt = (payload: SearchPayload | null): Date | null => { + const createdAtDirect = toDateValue(readPayloadValue(payload, [ + 'created_at', + 'createdAt', + 'created_date', + 'createdDate', + 'created_time', + 'createdTime', + 'inserted_at', + 'insertedAt' + ])); + if (createdAtDirect) return createdAtDirect; + + const createdRecord = toRecord(readPayloadValue(payload, ['created'])); + if (!createdRecord) return null; + return toDateValue(createdRecord['at']) ?? toDateValue(createdRecord['date']) ?? toDateValue(createdRecord['time']); +}; + +const formatRelativeTimeFromNow = (targetDate: Date): string => { + const deltaSeconds = Math.round((targetDate.getTime() - Date.now()) / 1000); + const absSeconds = Math.abs(deltaSeconds); + const formatter = new Intl.RelativeTimeFormat(String(locale.value || 'en'), { numeric: 'auto' }); + + if (absSeconds < 60) return formatter.format(deltaSeconds, 'second'); + const deltaMinutes = Math.round(deltaSeconds / 60); + if (Math.abs(deltaMinutes) < 60) return formatter.format(deltaMinutes, 'minute'); + const deltaHours = Math.round(deltaSeconds / 3600); + if (Math.abs(deltaHours) < 24) return formatter.format(deltaHours, 'hour'); + const deltaDays = Math.round(deltaSeconds / 86400); + if (Math.abs(deltaDays) < 30) return formatter.format(deltaDays, 'day'); + const deltaMonths = Math.round(deltaSeconds / 2_592_000); + if (Math.abs(deltaMonths) < 12) return formatter.format(deltaMonths, 'month'); + return formatter.format(Math.round(deltaSeconds / 31_536_000), 'year'); +}; + +const formatAbsoluteDateTime = (targetDate: Date): string => ( + new Intl.DateTimeFormat(String(locale.value || 'en'), { dateStyle: 'medium', timeStyle: 'short' }).format(targetDate) +); + +const isCancelledBooking = (entityAlias: string, payload: SearchPayload | null): boolean => { + if (entityAlias !== 'bookings') return false; + const cancelledFlags = [ + readPayloadValue(payload, ['cancelled', 'canceled', 'is_cancelled', 'isCancelled', 'is_canceled', 'isCanceled']), + readPayloadValue(payload, ['status_cancelled', 'statusCanceled']) + ]; + if (cancelledFlags.some((flag) => toBoolean(flag) === true)) return true; + + const statusAlias = toAliasKey(readPayloadValue(payload, ['status', 'booking_status', 'bookingStatus', 'state'])); + if (!statusAlias) return false; + return CANCELLED_STATUS_ALIASES.has(statusAlias) || statusAlias.startsWith('cancel'); +}; + const extractDepartmentIdFromPayload = (result: SearchResult, payload: SearchPayload | null): number | null => { if (typeof result.department_id === 'number' && Number.isFinite(result.department_id)) return result.department_id; if (!payload) return null; @@ -898,6 +1025,11 @@ type VisibleResultCard = { navigationTarget: SystemSearchNavigationTarget | null; customerNumber: number | null; departmentId: number | null; + displayTypeLabel: string; + iconName: string; + createdAgoLabel: string | null; + createdAtLabel: string | null; + isCancelledBooking: boolean; }; const accessContext = computed(() => ({ @@ -909,12 +1041,20 @@ const accessContext = computed(() => ({ const visibleResultCards = computed(() => visibleResults.value.map((row) => { const viewModel = buildSystemSearchViewModel(row); + const payload = viewModel.rawPayload; + const resolvedAlias = resolveUiEntityAlias(row, payload); + const createdAt = extractCreatedAt(payload); return { row, viewModel, navigationTarget: resolveSystemSearchNavigationTarget(row, accessContext.value, { searchQuery: query.value.trim() }), - customerNumber: extractCustomerNumberFromPayload(row, viewModel.rawPayload), - departmentId: extractDepartmentIdFromPayload(row, viewModel.rawPayload) + customerNumber: extractCustomerNumberFromPayload(row, payload), + departmentId: extractDepartmentIdFromPayload(row, payload), + displayTypeLabel: resolveUiTypeLabel(row, payload), + iconName: resolveEntityIconName(resolvedAlias), + createdAgoLabel: createdAt ? formatRelativeTimeFromNow(createdAt) : null, + createdAtLabel: createdAt ? formatAbsoluteDateTime(createdAt) : null, + isCancelledBooking: isCancelledBooking(resolvedAlias, payload) }; })); @@ -1217,10 +1357,18 @@ const asJson = (v: unknown) => {