|
|
|
@@ -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;
|
|
|
|
|
}
|
|
|
|
|