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`.
This commit is contained in:
Jeppe Bundgaard
2026-03-13 00:50:54 +01:00
parent 16baa28b85
commit dfe8eaa882
6 changed files with 213 additions and 6 deletions
@@ -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<SearchMethod>('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<SystemSearchAccessContext>(() => ({
@@ -909,12 +1041,20 @@ const accessContext = computed<SystemSearchAccessContext>(() => ({
const visibleResultCards = computed<VisibleResultCard[]>(() => 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) => {
<template v-if="visibleResultCards.length">
<div class="result-list">
<article v-for="card in visibleResultCards" :key="`${card.row.entity_type}-${card.row.entity_id}-${card.row.score}`" class="result-item">
<article
v-for="card in visibleResultCards"
:key="`${card.row.entity_type}-${card.row.entity_id}-${card.row.score}`"
class="result-item"
:class="{ 'result-item--cancelled': card.isCancelledBooking }"
>
<div class="result-item-header mb-2">
<p class="result-title is-size-7 has-text-weight-semibold mb-0">{{ card.viewModel.title }}</p>
<p class="result-type-label is-size-7 mb-0">{{ humanize(card.row.entity_type) }}</p>
<p class="result-type-label is-size-7 mb-0">
<b-icon :icon="card.iconName" pack="fas" size="is-small" class="result-type-icon" />
<span>{{ card.displayTypeLabel }}</span>
</p>
</div>
<p v-if="card.viewModel.description" class="result-description is-size-7 has-text-grey-dark mb-2">{{ card.viewModel.description }}</p>
<ul v-if="getCardBadges(card).length" class="result-badges result-badge-list mb-2">
@@ -1241,6 +1389,18 @@ const asJson = (v: unknown) => {
<strong>{{ field.label }}:</strong> {{ field.value }}
</p>
</div>
<p
v-if="card.createdAgoLabel"
class="result-info-line result-info-line--created is-size-7 mb-1"
:title="card.createdAtLabel || undefined"
>
<b-icon icon="clock" pack="fas" size="is-small" class="result-info-icon" />
<strong>{{ t('global_search.results.created') }}:</strong> {{ card.createdAgoLabel }}
</p>
<p v-if="card.isCancelledBooking" class="result-info-line result-info-line--cancelled is-size-7 mb-1">
<b-icon icon="ban" pack="fas" size="is-small" class="result-info-icon" />
<strong>{{ t('global_search.results.status') }}:</strong> {{ t('global_search.results.cancelled') }}
</p>
<p class="result-info-line is-size-7 mb-1"><strong>{{ t('global_search.results.id') }}:</strong> {{ card.row.entity_id }}</p>
<p v-if="card.row.association_reason" class="result-info-line result-info-line--association is-size-7 mb-2"><strong>{{ t('global_search.results.association') }}:</strong> {{ card.row.association_reason }}</p>
<div class="result-actions-row">
@@ -1621,6 +1781,13 @@ const asJson = (v: unknown) => {
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.1);
border-color: rgba(37, 99, 235, 0.2);
}
.result-item--cancelled {
border-color: rgba(239, 68, 68, 0.35);
background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);
}
.result-item--cancelled .result-title {
color: #b91c1c;
}
.result-item-header {
display: flex;
align-items: flex-start;
@@ -1636,10 +1803,16 @@ const asJson = (v: unknown) => {
}
.result-type-label {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
color: #3b4d66;
font-weight: 600;
text-transform: capitalize;
}
.result-type-icon {
color: #2563eb;
}
.result-description {
color: var(--search-text-subtle);
}
@@ -1689,8 +1862,23 @@ const asJson = (v: unknown) => {
line-height: 1.4;
}
.result-info-line {
display: flex;
align-items: center;
gap: 6px;
color: var(--search-text-subtle);
}
.result-info-icon {
color: #64748b;
}
.result-info-line--created .result-info-icon {
color: #2563eb;
}
.result-info-line--cancelled {
color: #b91c1c;
}
.result-info-line--cancelled .result-info-icon {
color: #dc2626;
}
.result-info-line--association {
color: #1d4ed8;
}
+4
View File
@@ -3959,6 +3959,9 @@
"range": "Resultater {start}-{end} af {total}",
"group_range": "{group}: {start}-{end} af {total}",
"id": "ID",
"created": "Created",
"status": "Status",
"cancelled": "Cancelled",
"association": "Relation",
"payload": "Payload",
"open": "Åbn",
@@ -4197,3 +4200,4 @@
}
}
}
+4
View File
@@ -3885,6 +3885,9 @@
"range": "Ergebnisse {start}-{end} von {total}",
"group_range": "{group}: {start}-{end} von {total}",
"id": "ID",
"created": "Created",
"status": "Status",
"cancelled": "Cancelled",
"association": "Zuordnung",
"payload": "Nutzdaten",
"open": "?ffnen",
@@ -4195,3 +4198,4 @@
}
}
}
+3
View File
@@ -4102,6 +4102,9 @@
"range": "Results {start}-{end} of {total}",
"group_range": "{group}: {start}-{end} of {total}",
"id": "ID",
"created": "Created",
"status": "Status",
"cancelled": "Cancelled",
"association": "Association",
"payload": "Payload",
"open": "Open",
+4
View File
@@ -3915,6 +3915,9 @@
"range": "Resultater {start}-{end} av {total}",
"group_range": "{group}: {start}-{end} av {total}",
"id": "ID",
"created": "Created",
"status": "Status",
"cancelled": "Cancelled",
"association": "Forening",
"payload": "Nyttelast",
"open": "Åpne",
@@ -4195,3 +4198,4 @@
}
}
}
+4
View File
@@ -3915,6 +3915,9 @@
"range": "Resultat {start}-{end} av {total}",
"group_range": "{group}: {start}-{end} av {total}",
"id": "ID",
"created": "Created",
"status": "Status",
"cancelled": "Cancelled",
"association": "Koppling",
"payload": "Nyttolast",
"open": "öppna",
@@ -4195,3 +4198,4 @@
}
}
}