Add unit tests for superuser-vehicle-analytics, superuser-vehicle-view, and advanced system search components:
- Cover query builders, data normalization, and analytics aggregations for vehicle view utilities. - Validate route contracts, tab structure, and OpenAPI integration for `superuser-vehicle-view`. - Test advanced search modal logic, route registration, registry compliance, and UI behavior. - Ensure system search registry and navigation resolvers provide complete type coverage and fallback handling.
This commit is contained in:
@@ -0,0 +1,850 @@
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
|
||||
export const SYSTEM_SEARCH_ENTITY_TYPES = [
|
||||
'objects', 'module_config', 'orders', 'order_items', 'customers', 'employees', 'subusers',
|
||||
'customer_discounts', 'customer_fixed_prices', 'departments', 'permissions', 'roles', 'invoices', 'vehicles',
|
||||
'bookings', 'bookings_new', 'branding', 'categories', 'currency_conversion_rates', 'customer_codes',
|
||||
'customer_default_department', 'customer_notes', 'customer_vehicles_addons', 'department_categories',
|
||||
'department_daily_reports', 'department_gates', 'department_goals', 'department_lanes',
|
||||
'department_notification_sms', 'department_relays', 'department_selfserve_condition_rules',
|
||||
'department_selfserve_conditions', 'department_selfserve_questions', 'department_selfserve_tasks',
|
||||
'department_selfserve_vehicle_conditions', 'department_time_bookings_entries',
|
||||
'department_time_bookings_opening_hours', 'department_time_bookings_types', 'department_variables',
|
||||
'fxratesapi_conversion_rates', 'module_action_logs', 'motorapi_lookups', 'notifications', 'order_bookings',
|
||||
'plate_scanners', 'plate_scans', 'product_options', 'products', 'stripe_module_customers',
|
||||
'stripe_module_orders', 'stripe_payment_intents', 'subuser_grants', 'xlvask_customers',
|
||||
'xlvask_potential_order_matches', 'xlvask_usage_log_wash_items', 'xlvask_usage_logs',
|
||||
'xlvask_vehicle_types', 'xlvask_vehicles'
|
||||
] as const;
|
||||
|
||||
export type EntityType = typeof SYSTEM_SEARCH_ENTITY_TYPES[number];
|
||||
|
||||
export type SearchResult = {
|
||||
entity_type: EntityType;
|
||||
entity_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
customer_number?: number | null;
|
||||
department_id?: number | null;
|
||||
score: number;
|
||||
association_reason?: string | null;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export type SearchPayload = Record<string, unknown>;
|
||||
|
||||
export type SystemSearchNavigationStrategy = 'deep-link' | 'module' | 'generic';
|
||||
|
||||
export type SystemSearchNavigationTarget = {
|
||||
strategy: SystemSearchNavigationStrategy;
|
||||
to: RouteLocationRaw;
|
||||
};
|
||||
|
||||
export type SystemSearchBadge = {
|
||||
label: string;
|
||||
type: 'is-info' | 'is-success' | 'is-warning' | 'is-light';
|
||||
};
|
||||
|
||||
export type SystemSearchKeyField = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SystemSearchViewModel = {
|
||||
title: string;
|
||||
description: string;
|
||||
badges: SystemSearchBadge[];
|
||||
keyFields: SystemSearchKeyField[];
|
||||
rawPayload: SearchPayload | null;
|
||||
parsedPayload: SearchPayload | null;
|
||||
};
|
||||
|
||||
export type SystemSearchAccessContext = {
|
||||
canAccessSuperUser: boolean;
|
||||
canAccessAdmin: boolean;
|
||||
canAccessUser: boolean;
|
||||
canAccessDepartment: (departmentId: number) => boolean;
|
||||
};
|
||||
|
||||
export type SystemSearchNavigationResolveContext = {
|
||||
searchQuery?: string;
|
||||
allowGenericFallback?: boolean;
|
||||
};
|
||||
|
||||
type FieldSelector = {
|
||||
label: string;
|
||||
keys: string[];
|
||||
};
|
||||
|
||||
type NavigationResolver = (
|
||||
result: SearchResult,
|
||||
payload: SearchPayload | null,
|
||||
access: SystemSearchAccessContext,
|
||||
context?: SystemSearchNavigationResolveContext
|
||||
) => SystemSearchNavigationTarget | null;
|
||||
|
||||
export type SystemSearchSupportDefinition = {
|
||||
entityType: EntityType;
|
||||
navigationStrategy: SystemSearchNavigationStrategy;
|
||||
titleKeys: string[];
|
||||
descriptionKeys: string[];
|
||||
keyFieldSelectors: FieldSelector[];
|
||||
resolveNavigation: NavigationResolver;
|
||||
};
|
||||
|
||||
const deepLinkTypes = new Set<EntityType>([
|
||||
'orders',
|
||||
'order_items',
|
||||
'vehicles',
|
||||
'invoices',
|
||||
'products',
|
||||
'departments',
|
||||
'roles'
|
||||
]);
|
||||
|
||||
const moduleTypes = new Set<EntityType>([
|
||||
'bookings',
|
||||
'bookings_new',
|
||||
'order_bookings',
|
||||
'department_selfserve_condition_rules',
|
||||
'department_selfserve_conditions',
|
||||
'department_selfserve_questions',
|
||||
'department_selfserve_tasks',
|
||||
'department_selfserve_vehicle_conditions',
|
||||
'department_time_bookings_entries',
|
||||
'department_time_bookings_opening_hours',
|
||||
'department_time_bookings_types',
|
||||
'department_daily_reports',
|
||||
'department_goals',
|
||||
'department_notification_sms',
|
||||
'department_lanes',
|
||||
'department_gates',
|
||||
'department_relays',
|
||||
'categories',
|
||||
'customers',
|
||||
'subusers',
|
||||
'subuser_grants',
|
||||
'plate_scanners',
|
||||
'plate_scans',
|
||||
'xlvask_customers',
|
||||
'xlvask_usage_logs',
|
||||
'xlvask_vehicles',
|
||||
'xlvask_vehicle_types',
|
||||
'motorapi_lookups',
|
||||
'fxratesapi_conversion_rates',
|
||||
'currency_conversion_rates',
|
||||
'stripe_module_customers',
|
||||
'stripe_module_orders',
|
||||
'stripe_payment_intents',
|
||||
'module_config'
|
||||
]);
|
||||
|
||||
const field = (label: string, ...keys: string[]): FieldSelector => ({ label, keys });
|
||||
const fieldList = (...entries: FieldSelector[]): FieldSelector[] => entries;
|
||||
|
||||
const defaultTitleKeys = ['name', 'reference', 'title', 'external_id', 'id'];
|
||||
const defaultDescriptionKeys = ['description', 'notes', 'note', 'status', 'email', 'address'];
|
||||
const defaultKeyFields = fieldList(
|
||||
field('Name', 'name'),
|
||||
field('Reference', 'reference'),
|
||||
field('Status', 'status'),
|
||||
field('Updated', 'updated', 'updated_at'),
|
||||
field('Created', 'created_at')
|
||||
);
|
||||
|
||||
const titleKeyOverrides: Partial<Record<EntityType, string[]>> = {
|
||||
orders: ['reference', 'reg_1', 'id'],
|
||||
order_items: ['reference', 'order_id', 'id'],
|
||||
bookings: ['reference_number', 'regNrTraekker', 'id'],
|
||||
bookings_new: ['reference_number', 'regNrTraekker', 'id'],
|
||||
order_bookings: ['reference', 'po', 'reg_1', 'id'],
|
||||
products: ['name', 'id'],
|
||||
xlvask_usage_logs: ['RegistrationNumber', 'WashId', 'id'],
|
||||
xlvask_customers: ['name', 'customerId', 'id'],
|
||||
xlvask_vehicles: ['registrationNumber', 'vehicleId', 'id'],
|
||||
customer_discounts: ['customer_number', 'product_or_category_id', 'id'],
|
||||
invoices: ['external_id', 'name', 'id'],
|
||||
motorapi_lookups: ['license_plate', 'id'],
|
||||
fxratesapi_conversion_rates: ['base', 'target', 'id'],
|
||||
module_config: ['key', 'name', 'id'],
|
||||
stripe_module_customers: ['customer_id', 'id'],
|
||||
stripe_module_orders: ['order_id', 'id'],
|
||||
stripe_payment_intents: ['payment_intent_id', 'id']
|
||||
};
|
||||
|
||||
const descriptionKeyOverrides: Partial<Record<EntityType, string[]>> = {
|
||||
orders: ['notes', 'reference', 'reg_1'],
|
||||
bookings: ['notes', 'status', 'wash_type'],
|
||||
bookings_new: ['notes', 'status', 'wash_type'],
|
||||
order_bookings: ['note', 'reference', 'datetime'],
|
||||
xlvask_usage_logs: ['Customer', 'Location', 'VehicleType', 'StartTime'],
|
||||
xlvask_customers: ['email', 'city', 'address', 'vatnumber'],
|
||||
motorapi_lookups: ['endpoint', 'license_plate', 'result'],
|
||||
fxratesapi_conversion_rates: ['base', 'target', 'endpoint', 'result'],
|
||||
customer_discounts: ['customer_number', 'percentage'],
|
||||
module_config: ['description', 'value']
|
||||
};
|
||||
|
||||
const keyFieldOverrides: Partial<Record<EntityType, FieldSelector[]>> = {
|
||||
orders: fieldList(
|
||||
field('Order ID', 'id', 'order_id'),
|
||||
field('Reference', 'reference'),
|
||||
field('Registration', 'reg_1'),
|
||||
field('Customer', 'customer_id')
|
||||
),
|
||||
order_items: fieldList(
|
||||
field('Order', 'order_id'),
|
||||
field('Product', 'product_id'),
|
||||
field('Reference', 'reference')
|
||||
),
|
||||
vehicles: fieldList(
|
||||
field('Registration', 'reg', 'registration_number', 'registrationNumber'),
|
||||
field('Customer', 'customer_number', 'customer_id'),
|
||||
field('Vehicle ID', 'vehicleId', 'id')
|
||||
),
|
||||
invoices: fieldList(
|
||||
field('Invoice ID', 'id'),
|
||||
field('External ID', 'external_id'),
|
||||
field('Customer', 'customer_number')
|
||||
),
|
||||
products: fieldList(
|
||||
field('Price', 'price'),
|
||||
field('Category', 'category'),
|
||||
field('Wash', 'is_wash'),
|
||||
field('Product ID', 'economic_product_id', 'id')
|
||||
),
|
||||
bookings: fieldList(
|
||||
field('Date', 'date'),
|
||||
field('Reference', 'reference_number'),
|
||||
field('Reg #1', 'regNrTraekker'),
|
||||
field('Status', 'status')
|
||||
),
|
||||
bookings_new: fieldList(
|
||||
field('Date', 'date'),
|
||||
field('Reference', 'reference_number'),
|
||||
field('Reg #1', 'regNrTraekker'),
|
||||
field('Status', 'status')
|
||||
),
|
||||
order_bookings: fieldList(
|
||||
field('Date/Time', 'datetime'),
|
||||
field('Reg #1', 'reg_1'),
|
||||
field('Reference', 'reference', 'po'),
|
||||
field('Items', 'items')
|
||||
),
|
||||
xlvask_usage_logs: fieldList(
|
||||
field('Registration', 'RegistrationNumber'),
|
||||
field('Location', 'Location'),
|
||||
field('Start', 'StartTime'),
|
||||
field('Status', 'FinishStatus'),
|
||||
field('Customer', 'Customer')
|
||||
),
|
||||
xlvask_customers: fieldList(
|
||||
field('Customer ID', 'customerId'),
|
||||
field('Email', 'email'),
|
||||
field('City', 'city'),
|
||||
field('VAT', 'vatnumber')
|
||||
),
|
||||
xlvask_vehicles: fieldList(
|
||||
field('Vehicle ID', 'vehicleId'),
|
||||
field('Registration', 'registrationNumber'),
|
||||
field('Customer ID', 'customerId'),
|
||||
field('Active', 'active')
|
||||
),
|
||||
motorapi_lookups: fieldList(
|
||||
field('License plate', 'license_plate'),
|
||||
field('Endpoint', 'endpoint'),
|
||||
field('Result', 'result')
|
||||
),
|
||||
fxratesapi_conversion_rates: fieldList(
|
||||
field('Base', 'base'),
|
||||
field('Target', 'target'),
|
||||
field('Endpoint', 'endpoint'),
|
||||
field('Result', 'result')
|
||||
),
|
||||
module_config: fieldList(
|
||||
field('Key', 'key'),
|
||||
field('Value', 'value'),
|
||||
field('Environment', 'environment')
|
||||
),
|
||||
stripe_module_customers: fieldList(
|
||||
field('Stripe customer', 'stripe_customer_id', 'customer_id'),
|
||||
field('Customer #', 'customer_number'),
|
||||
field('Email', 'email')
|
||||
),
|
||||
stripe_module_orders: fieldList(
|
||||
field('Order', 'order_id'),
|
||||
field('Amount', 'amount', 'total'),
|
||||
field('Status', 'status')
|
||||
),
|
||||
stripe_payment_intents: fieldList(
|
||||
field('Payment intent', 'payment_intent_id', 'id'),
|
||||
field('Amount', 'amount'),
|
||||
field('Status', 'status')
|
||||
)
|
||||
};
|
||||
|
||||
const normalizeText = (value: unknown): string => {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
export const humanizeEntityType = (value: string): string => value.replace(/_/g, ' ');
|
||||
|
||||
export const toNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
const toScalar = (value: unknown): string => {
|
||||
if (value === null || typeof value === 'undefined') return '';
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '';
|
||||
if (typeof value === 'string') return normalizeText(value);
|
||||
if (Array.isArray(value)) return value.length ? `${value.length} entries` : '';
|
||||
if (typeof value === 'object') {
|
||||
const keys = Object.keys(value as Record<string, unknown>);
|
||||
return keys.length ? `${keys.length} fields` : '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const MAX_JSON_PARSE_LENGTH = 120000;
|
||||
const JSON_LIKE_RE = /^[\[{]/;
|
||||
|
||||
const parseJsonLike = (value: unknown): unknown | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > MAX_JSON_PARSE_LENGTH || !JSON_LIKE_RE.test(trimmed)) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const summarizeParsedValue = (key: string, value: unknown): string => {
|
||||
if (Array.isArray(value)) {
|
||||
if (key === 'items') {
|
||||
const names = value
|
||||
.map((entry) => (entry && typeof entry === 'object' ? (entry as Record<string, unknown>).name : null))
|
||||
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
|
||||
.slice(0, 3)
|
||||
.map((name) => normalizeText(name));
|
||||
return names.length ? `${value.length} items: ${names.join(', ')}` : `${value.length} items`;
|
||||
}
|
||||
return `${value.length} entries`;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
if (key === 'result') {
|
||||
if (typeof record.registration_number === 'string') {
|
||||
const vehicle = [record.make, record.model]
|
||||
.map((part) => toScalar(part))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return vehicle ? `${record.registration_number} / ${vehicle}` : String(record.registration_number);
|
||||
}
|
||||
if (typeof record.base === 'string' && record.rates && typeof record.rates === 'object') {
|
||||
const rateCount = Object.keys(record.rates as Record<string, unknown>).length;
|
||||
return `${record.base} rates (${rateCount})`;
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Object.keys(record);
|
||||
return keys.length ? `${keys.length} fields` : '';
|
||||
};
|
||||
|
||||
const formatFieldValue = (key: string, value: unknown): string => {
|
||||
const parsed = parseJsonLike(value);
|
||||
if (parsed !== null) return summarizeParsedValue(key, parsed);
|
||||
return toScalar(value);
|
||||
};
|
||||
|
||||
export const normalizePayload = (payload: unknown): SearchPayload | null => {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
||||
return payload as SearchPayload;
|
||||
};
|
||||
|
||||
const parsePayloadForInspector = (payload: SearchPayload | null): SearchPayload | null => {
|
||||
if (!payload) return null;
|
||||
const parsed: SearchPayload = {};
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
parsed[key] = parseJsonLike(value) ?? value;
|
||||
});
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const readPayloadValue = (payload: SearchPayload | null, keys: string[]): unknown => {
|
||||
if (!payload) return null;
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, key)) {
|
||||
return payload[key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isTitleLowSignal = (result: SearchResult, title: string): boolean => {
|
||||
if (!title) return true;
|
||||
if (title === result.entity_id) return true;
|
||||
const fallback = `${humanizeEntityType(result.entity_type)} #${result.entity_id}`;
|
||||
return title.toLowerCase() === fallback.toLowerCase();
|
||||
};
|
||||
|
||||
const extractTitle = (result: SearchResult, payload: SearchPayload | null, titleKeys: string[]): string => {
|
||||
const resultTitle = normalizeText(result.title);
|
||||
const payloadTitle = toScalar(readPayloadValue(payload, titleKeys));
|
||||
if (isTitleLowSignal(result, resultTitle)) {
|
||||
return payloadTitle || resultTitle || `${humanizeEntityType(result.entity_type)} #${result.entity_id}`;
|
||||
}
|
||||
return resultTitle;
|
||||
};
|
||||
|
||||
const extractDescription = (result: SearchResult, payload: SearchPayload | null, descriptionKeys: string[]): string => {
|
||||
const fromResult = normalizeText(result.description);
|
||||
if (fromResult) return fromResult;
|
||||
return toScalar(readPayloadValue(payload, descriptionKeys));
|
||||
};
|
||||
|
||||
const extractDepartmentId = (result: SearchResult, payload: SearchPayload | null): number | null => {
|
||||
if (typeof result.department_id === 'number') return result.department_id;
|
||||
return (
|
||||
toNumber(readPayloadValue(payload, ['department_id'])) ??
|
||||
toNumber(readPayloadValue(payload, ['departmentId'])) ??
|
||||
toNumber(readPayloadValue(payload, ['department']))
|
||||
);
|
||||
};
|
||||
|
||||
const extractCustomerNumber = (result: SearchResult, payload: SearchPayload | null): number | null => {
|
||||
if (typeof result.customer_number === 'number') return result.customer_number;
|
||||
return (
|
||||
toNumber(readPayloadValue(payload, ['customer_number'])) ??
|
||||
toNumber(readPayloadValue(payload, ['customerNumber'])) ??
|
||||
toNumber(readPayloadValue(payload, ['customer_id'])) ??
|
||||
toNumber(readPayloadValue(payload, ['CustomerId']))
|
||||
);
|
||||
};
|
||||
|
||||
const buildBadges = (result: SearchResult, payload: SearchPayload | null): SystemSearchBadge[] => {
|
||||
const badges: SystemSearchBadge[] = [{ label: `Score ${result.score}`, type: 'is-info' }];
|
||||
const customer = extractCustomerNumber(result, payload);
|
||||
const department = extractDepartmentId(result, payload);
|
||||
if (customer !== null) badges.push({ label: `Customer #${customer}`, type: 'is-success' });
|
||||
if (department !== null) badges.push({ label: `Department #${department}`, type: 'is-warning' });
|
||||
if (normalizeText(result.association_reason)) badges.push({ label: 'Association', type: 'is-light' });
|
||||
return badges;
|
||||
};
|
||||
|
||||
const buildKeyFields = (payload: SearchPayload | null, selectors: FieldSelector[]): SystemSearchKeyField[] => {
|
||||
if (!payload) return [];
|
||||
const fields: SystemSearchKeyField[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const selector of selectors) {
|
||||
const raw = readPayloadValue(payload, selector.keys);
|
||||
const value = formatFieldValue(selector.keys[0], raw);
|
||||
if (!value) continue;
|
||||
const dedupeKey = `${selector.label}:${value}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
fields.push({ label: selector.label, value });
|
||||
if (fields.length >= 6) break;
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
const buildGenericRecordRoute = (result: SearchResult, context?: SystemSearchNavigationResolveContext): RouteLocationRaw => {
|
||||
const query: Record<string, string> = {
|
||||
score: String(result.score)
|
||||
};
|
||||
if (context?.searchQuery && context.searchQuery.trim()) query.q = context.searchQuery.trim();
|
||||
if (typeof result.department_id === 'number') query.department_id = String(result.department_id);
|
||||
if (typeof result.customer_number === 'number') query.customer_number = String(result.customer_number);
|
||||
|
||||
return {
|
||||
path: `/search/system/record/${encodeURIComponent(result.entity_type)}/${encodeURIComponent(result.entity_id)}`,
|
||||
query,
|
||||
state: {
|
||||
systemSearchResult: JSON.parse(JSON.stringify(result)),
|
||||
systemSearchQuery: context?.searchQuery ?? ''
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const genericTarget = (
|
||||
result: SearchResult,
|
||||
context?: SystemSearchNavigationResolveContext
|
||||
): SystemSearchNavigationTarget | null => {
|
||||
if (context?.allowGenericFallback === false) return null;
|
||||
return {
|
||||
strategy: 'generic',
|
||||
to: buildGenericRecordRoute(result, context)
|
||||
};
|
||||
};
|
||||
|
||||
const target = (strategy: SystemSearchNavigationStrategy, path: string): SystemSearchNavigationTarget => ({
|
||||
strategy,
|
||||
to: { path }
|
||||
});
|
||||
|
||||
const embeddedRouteTarget = (payload: SearchPayload | null): SystemSearchNavigationTarget | null => {
|
||||
const route = toScalar(readPayloadValue(payload, ['path', 'url', 'route', 'to']));
|
||||
if (!route || !route.startsWith('/')) return null;
|
||||
return target('deep-link', route);
|
||||
};
|
||||
|
||||
const resolveOrders: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
const orderId = toScalar(readPayloadValue(payload, ['order_id', 'id'])) || result.entity_id;
|
||||
const departmentId = extractDepartmentId(result, payload);
|
||||
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
|
||||
return target('deep-link', `/admin/${departmentId}/modules/pos/orders/${encodeURIComponent(orderId)}`);
|
||||
}
|
||||
if (access.canAccessUser) {
|
||||
return target('deep-link', `/user/orders/${encodeURIComponent(orderId)}`);
|
||||
}
|
||||
if (access.canAccessSuperUser) {
|
||||
return target('module', '/superuser/orders');
|
||||
}
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveOrderItems: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
const departmentId = extractDepartmentId(result, payload);
|
||||
const orderId = toScalar(readPayloadValue(payload, ['order_id', 'orderId']));
|
||||
if (departmentId !== null && orderId && access.canAccessDepartment(departmentId)) {
|
||||
return target('deep-link', `/admin/${departmentId}/modules/pos/orders/${encodeURIComponent(orderId)}`);
|
||||
}
|
||||
if (access.canAccessSuperUser) {
|
||||
return target('module', '/superuser/orders');
|
||||
}
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveVehicles: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
const registration = toScalar(readPayloadValue(payload, ['reg', 'registration_number', 'registrationNumber'])) || result.entity_id;
|
||||
if (access.canAccessSuperUser) {
|
||||
return target('deep-link', `/superuser/vehicles/${encodeURIComponent(registration)}`);
|
||||
}
|
||||
if (access.canAccessUser) {
|
||||
return target('module', '/user/vehicles');
|
||||
}
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveInvoices: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
const invoiceId = toNumber(result.entity_id) ?? toNumber(readPayloadValue(payload, ['id']));
|
||||
if (access.canAccessSuperUser) {
|
||||
return invoiceId
|
||||
? target('deep-link', `/superuser/invoices/${invoiceId}`)
|
||||
: target('module', '/superuser/invoices');
|
||||
}
|
||||
if (access.canAccessUser) {
|
||||
return target('module', '/user/invoices');
|
||||
}
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveProducts: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (!access.canAccessSuperUser) return genericTarget(result, context);
|
||||
const productId = toScalar(readPayloadValue(payload, ['id'])) || result.entity_id;
|
||||
return target('deep-link', `/superuser/products/${encodeURIComponent(productId)}`);
|
||||
};
|
||||
|
||||
const resolveDepartments: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
const departmentId = extractDepartmentId(result, payload) ?? toNumber(result.entity_id);
|
||||
if (departmentId !== null && access.canAccessSuperUser) {
|
||||
return target('deep-link', `/superuser/departments/${departmentId}`);
|
||||
}
|
||||
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
|
||||
return target('deep-link', `/admin/${departmentId}`);
|
||||
}
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/departments');
|
||||
if (access.canAccessAdmin) return target('module', '/admin');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveRoles: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (!access.canAccessSuperUser) return genericTarget(result, context);
|
||||
const roleId = toScalar(readPayloadValue(payload, ['id'])) || result.entity_id;
|
||||
return target('deep-link', `/superuser/roles/${encodeURIComponent(roleId)}`);
|
||||
};
|
||||
|
||||
const resolveDepartmentModule = (modulePath: string): NavigationResolver => (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
const departmentId = extractDepartmentId(result, payload);
|
||||
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
|
||||
return target('module', `/admin/${departmentId}/${modulePath}`);
|
||||
}
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveBookings: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
const departmentId = extractDepartmentId(result, payload);
|
||||
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
|
||||
return target('module', `/admin/${departmentId}/modules/bookings`);
|
||||
}
|
||||
if (access.canAccessUser) {
|
||||
return target('module', '/user/bookings');
|
||||
}
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveDepartmentLanes: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
const departmentId = extractDepartmentId(result, payload);
|
||||
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
|
||||
return target('module', `/admin/${departmentId}/modules/wash-lanes`);
|
||||
}
|
||||
if (access.canAccessSuperUser) {
|
||||
return target('module', '/superuser/department/lanes');
|
||||
}
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveDepartmentGates: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/department/gates');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveDepartmentRelays: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/department/relays');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveCategories: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/categories');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveCustomers: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/customers');
|
||||
if (access.canAccessUser) return target('module', '/user/profile');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveSubusers: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessUser) return target('module', '/user/subusers');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveSubuserGrants: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessUser) return target('module', '/user/subusers/grants');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveScanners: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/scanners');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveXLVaskCustomers: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/xlvask/customers');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveXLVaskUsageLogs: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/xlvask/usagelogs');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveXLVaskGeneric: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/xlvask');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveMotorApi: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/configuration/motorapi');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveFxRates: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/configuration/fxratesapi');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveStripe: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/configuration/stripe');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveModuleConfig: NavigationResolver = (result, payload, access, context) => {
|
||||
const explicit = embeddedRouteTarget(payload);
|
||||
if (explicit) return explicit;
|
||||
if (access.canAccessSuperUser) return target('module', '/superuser/configuration');
|
||||
return genericTarget(result, context);
|
||||
};
|
||||
|
||||
const resolveGeneric: NavigationResolver = (result, _payload, _access, context) => genericTarget(result, context);
|
||||
|
||||
const resolveNavigationForType = (entityType: EntityType): NavigationResolver => {
|
||||
switch (entityType) {
|
||||
case 'orders':
|
||||
return resolveOrders;
|
||||
case 'order_items':
|
||||
return resolveOrderItems;
|
||||
case 'vehicles':
|
||||
return resolveVehicles;
|
||||
case 'invoices':
|
||||
return resolveInvoices;
|
||||
case 'products':
|
||||
return resolveProducts;
|
||||
case 'departments':
|
||||
return resolveDepartments;
|
||||
case 'roles':
|
||||
return resolveRoles;
|
||||
case 'bookings':
|
||||
case 'bookings_new':
|
||||
case 'order_bookings':
|
||||
return resolveBookings;
|
||||
case 'department_selfserve_questions':
|
||||
return resolveDepartmentModule('modules/self-serve/questions');
|
||||
case 'department_selfserve_tasks':
|
||||
return resolveDepartmentModule('modules/self-serve/tasks');
|
||||
case 'department_selfserve_conditions':
|
||||
return resolveDepartmentModule('modules/self-serve/conditions');
|
||||
case 'department_selfserve_condition_rules':
|
||||
return resolveDepartmentModule('modules/self-serve/condition-rules');
|
||||
case 'department_selfserve_vehicle_conditions':
|
||||
return resolveDepartmentModule('modules/self-serve/vehicle-conditions');
|
||||
case 'department_time_bookings_entries':
|
||||
return resolveDepartmentModule('modules/time-bookings');
|
||||
case 'department_time_bookings_opening_hours':
|
||||
return resolveDepartmentModule('modules/time-bookings/opening-hours');
|
||||
case 'department_time_bookings_types':
|
||||
return resolveDepartmentModule('modules/time-bookings/types');
|
||||
case 'department_daily_reports':
|
||||
return resolveDepartmentModule('modules/daily-report');
|
||||
case 'department_goals':
|
||||
return resolveDepartmentModule('modules/goals');
|
||||
case 'department_notification_sms':
|
||||
return resolveDepartmentModule('modules/notifications');
|
||||
case 'department_lanes':
|
||||
return resolveDepartmentLanes;
|
||||
case 'department_gates':
|
||||
return resolveDepartmentGates;
|
||||
case 'department_relays':
|
||||
return resolveDepartmentRelays;
|
||||
case 'categories':
|
||||
return resolveCategories;
|
||||
case 'customers':
|
||||
return resolveCustomers;
|
||||
case 'subusers':
|
||||
return resolveSubusers;
|
||||
case 'subuser_grants':
|
||||
return resolveSubuserGrants;
|
||||
case 'plate_scanners':
|
||||
case 'plate_scans':
|
||||
return resolveScanners;
|
||||
case 'xlvask_customers':
|
||||
return resolveXLVaskCustomers;
|
||||
case 'xlvask_usage_logs':
|
||||
return resolveXLVaskUsageLogs;
|
||||
case 'xlvask_vehicles':
|
||||
case 'xlvask_vehicle_types':
|
||||
return resolveXLVaskGeneric;
|
||||
case 'motorapi_lookups':
|
||||
return resolveMotorApi;
|
||||
case 'fxratesapi_conversion_rates':
|
||||
case 'currency_conversion_rates':
|
||||
return resolveFxRates;
|
||||
case 'stripe_module_customers':
|
||||
case 'stripe_module_orders':
|
||||
case 'stripe_payment_intents':
|
||||
return resolveStripe;
|
||||
case 'module_config':
|
||||
return resolveModuleConfig;
|
||||
default:
|
||||
return resolveGeneric;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveNavigationStrategy = (entityType: EntityType): SystemSearchNavigationStrategy => {
|
||||
if (deepLinkTypes.has(entityType)) return 'deep-link';
|
||||
if (moduleTypes.has(entityType)) return 'module';
|
||||
return 'generic';
|
||||
};
|
||||
|
||||
const buildDefinition = (entityType: EntityType): SystemSearchSupportDefinition => ({
|
||||
entityType,
|
||||
navigationStrategy: resolveNavigationStrategy(entityType),
|
||||
titleKeys: titleKeyOverrides[entityType] ?? defaultTitleKeys,
|
||||
descriptionKeys: descriptionKeyOverrides[entityType] ?? defaultDescriptionKeys,
|
||||
keyFieldSelectors: keyFieldOverrides[entityType] ?? defaultKeyFields,
|
||||
resolveNavigation: resolveNavigationForType(entityType)
|
||||
});
|
||||
|
||||
export const SYSTEM_SEARCH_SUPPORT_REGISTRY: Record<EntityType, SystemSearchSupportDefinition> =
|
||||
Object.fromEntries(SYSTEM_SEARCH_ENTITY_TYPES.map((entityType) => [entityType, buildDefinition(entityType)])) as Record<EntityType, SystemSearchSupportDefinition>;
|
||||
|
||||
export const resolveSystemSearchNavigationTarget = (
|
||||
result: SearchResult,
|
||||
access: SystemSearchAccessContext,
|
||||
context?: SystemSearchNavigationResolveContext
|
||||
): SystemSearchNavigationTarget | null => {
|
||||
const definition = SYSTEM_SEARCH_SUPPORT_REGISTRY[result.entity_type];
|
||||
const payload = normalizePayload(result.payload);
|
||||
return definition.resolveNavigation(result, payload, access, context);
|
||||
};
|
||||
|
||||
export const buildSystemSearchViewModel = (result: SearchResult): SystemSearchViewModel => {
|
||||
const definition = SYSTEM_SEARCH_SUPPORT_REGISTRY[result.entity_type];
|
||||
const payload = normalizePayload(result.payload);
|
||||
const title = extractTitle(result, payload, definition.titleKeys);
|
||||
const description = extractDescription(result, payload, definition.descriptionKeys);
|
||||
const keyFields = buildKeyFields(payload, definition.keyFieldSelectors);
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
badges: buildBadges(result, payload),
|
||||
keyFields,
|
||||
rawPayload: payload,
|
||||
parsedPayload: parsePayloadForInspector(payload)
|
||||
};
|
||||
};
|
||||
+576
-576
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import SessionUser from '@/components/session/token/SessionUser.vue';
|
||||
import {
|
||||
SYSTEM_SEARCH_ENTITY_TYPES,
|
||||
buildSystemSearchViewModel,
|
||||
humanizeEntityType,
|
||||
normalizePayload,
|
||||
resolveSystemSearchNavigationTarget,
|
||||
toNumber,
|
||||
type EntityType,
|
||||
type SearchResult,
|
||||
type SystemSearchAccessContext
|
||||
} from '@/components/viewport/page/headers/menu/systemSearchSupport';
|
||||
|
||||
type SearchResponse = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
results?: SearchResult[];
|
||||
};
|
||||
};
|
||||
|
||||
type HistoryStateRecord = {
|
||||
systemSearchResult?: SearchResult;
|
||||
systemSearchQuery?: string;
|
||||
usr?: {
|
||||
systemSearchResult?: SearchResult;
|
||||
systemSearchQuery?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const record = ref<SearchResult | null>(null);
|
||||
const source = ref<'state' | 'lookup' | 'none'>('none');
|
||||
|
||||
const requestedEntityType = computed(() => String(route.params.entityType ?? '').trim());
|
||||
const requestedEntityId = computed(() => String(route.params.entityId ?? '').trim());
|
||||
const routeQueryText = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''));
|
||||
|
||||
const entityType = computed<EntityType | null>(() => (
|
||||
SYSTEM_SEARCH_ENTITY_TYPES.includes(requestedEntityType.value as EntityType)
|
||||
? requestedEntityType.value as EntityType
|
||||
: null
|
||||
));
|
||||
|
||||
const accessContext = computed<SystemSearchAccessContext>(() => ({
|
||||
canAccessSuperUser: SessionUser.canAccessSuperUser(),
|
||||
canAccessAdmin: SessionUser.canAccessAdmin(),
|
||||
canAccessUser: SessionUser.canAccessUser(),
|
||||
canAccessDepartment: (departmentId: number) => SessionUser.canAccessDepartment(departmentId)
|
||||
}));
|
||||
|
||||
const parseErr = (e: unknown) => {
|
||||
const parsed = SessionUser.functions.parseErrorMessage(e);
|
||||
if (typeof parsed === 'string') return parsed;
|
||||
if (parsed && typeof parsed === 'object') return JSON.stringify(parsed);
|
||||
return 'Request failed.';
|
||||
};
|
||||
|
||||
const isSearchResult = (value: unknown): value is SearchResult => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate.entity_type === 'string'
|
||||
&& typeof candidate.entity_id === 'string'
|
||||
&& typeof candidate.title === 'string'
|
||||
);
|
||||
};
|
||||
|
||||
const readRouteStateRecord = (): SearchResult | null => {
|
||||
const routeState = (route as unknown as { state?: HistoryStateRecord }).state;
|
||||
const historyState = window.history.state as HistoryStateRecord | null;
|
||||
const candidates = [
|
||||
routeState?.systemSearchResult,
|
||||
historyState?.systemSearchResult,
|
||||
historyState?.usr?.systemSearchResult
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!isSearchResult(candidate)) continue;
|
||||
if (candidate.entity_type !== requestedEntityType.value) continue;
|
||||
if (String(candidate.entity_id) !== requestedEntityId.value) continue;
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const pickBestLookupMatch = (rows: SearchResult[], type: EntityType, entityId: string): SearchResult | null => {
|
||||
const exact = rows
|
||||
.filter((row) => row.entity_type === type && String(row.entity_id) === entityId)
|
||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
if (exact.length > 0) return exact[0];
|
||||
|
||||
const payloadIdMatches = rows
|
||||
.filter((row) => {
|
||||
if (row.entity_type !== type) return false;
|
||||
const payload = normalizePayload(row.payload);
|
||||
if (!payload) return false;
|
||||
return String(payload.id ?? '') === entityId;
|
||||
})
|
||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
if (payloadIdMatches.length > 0) return payloadIdMatches[0];
|
||||
|
||||
const firstForType = rows.find((row) => row.entity_type === type);
|
||||
return firstForType ?? null;
|
||||
};
|
||||
|
||||
const loadFromLookup = async () => {
|
||||
if (!entityType.value || !requestedEntityId.value) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const body = {
|
||||
query: requestedEntityId.value,
|
||||
include_types: [entityType.value],
|
||||
include_associations: true,
|
||||
debug_intent: false,
|
||||
limit: 50,
|
||||
offset: 0
|
||||
};
|
||||
const res = await SessionUser.request('/search/system', 'POST', body);
|
||||
const payload = (res?.data as SearchResponse | undefined)?.data;
|
||||
const rows = Array.isArray(payload?.results) ? payload.results : [];
|
||||
const match = pickBestLookupMatch(rows, entityType.value, requestedEntityId.value);
|
||||
if (!match) {
|
||||
source.value = 'none';
|
||||
error.value = 'No matching result found for this entity type.';
|
||||
return;
|
||||
}
|
||||
record.value = match;
|
||||
source.value = 'lookup';
|
||||
} catch (e) {
|
||||
source.value = 'none';
|
||||
error.value = parseErr(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecord = async () => {
|
||||
record.value = null;
|
||||
source.value = 'none';
|
||||
error.value = '';
|
||||
|
||||
if (!entityType.value) {
|
||||
error.value = `Unknown entity type: ${requestedEntityType.value}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const fromState = readRouteStateRecord();
|
||||
if (fromState) {
|
||||
record.value = fromState;
|
||||
source.value = 'state';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadFromLookup();
|
||||
};
|
||||
|
||||
const viewModel = computed(() => (record.value ? buildSystemSearchViewModel(record.value) : null));
|
||||
const navigationTarget = computed(() => (
|
||||
record.value
|
||||
? resolveSystemSearchNavigationTarget(record.value, accessContext.value, { allowGenericFallback: false })
|
||||
: null
|
||||
));
|
||||
|
||||
const quickLinks = computed(() => {
|
||||
const links: Array<{ label: string; path: string }> = [];
|
||||
const active = record.value;
|
||||
if (!active) return links;
|
||||
|
||||
const navTo = navigationTarget.value?.to as { path?: string } | undefined;
|
||||
if (navTo?.path) links.push({ label: 'Primary location', path: navTo.path });
|
||||
|
||||
const payload = normalizePayload(active.payload);
|
||||
const departmentId = typeof active.department_id === 'number'
|
||||
? active.department_id
|
||||
: toNumber(payload?.department ?? payload?.department_id ?? payload?.departmentId);
|
||||
if (departmentId && SessionUser.canAccessDepartment(departmentId)) {
|
||||
links.push({ label: `Department ${departmentId}`, path: `/admin/${departmentId}` });
|
||||
}
|
||||
|
||||
if (SessionUser.canAccessSuperUser()) {
|
||||
links.push({ label: 'Superuser home', path: '/superuser' });
|
||||
} else if (SessionUser.canAccessAdmin()) {
|
||||
links.push({ label: 'Department home', path: '/admin' });
|
||||
} else if (SessionUser.canAccessUser()) {
|
||||
links.push({ label: 'User home', path: '/user' });
|
||||
}
|
||||
|
||||
return links.filter((item, index, all) => all.findIndex((entry) => entry.path === item.path) === index);
|
||||
});
|
||||
|
||||
const asJson = (value: unknown) => {
|
||||
try { return JSON.stringify(value ?? {}, null, 2); } catch { return '{}'; }
|
||||
};
|
||||
|
||||
const openQuickLink = async (path: string) => {
|
||||
await router.push(path);
|
||||
};
|
||||
|
||||
watch(() => [route.params.entityType, route.params.entityId], () => {
|
||||
void loadRecord();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadRecord();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section system-search-record-page">
|
||||
<div class="container is-max-desktop">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="title is-5 mb-1">System Search Record</h1>
|
||||
<p class="is-size-7 has-text-grey mb-0">{{ humanizeEntityType(requestedEntityType || 'unknown') }} / {{ requestedEntityId }}</p>
|
||||
</div>
|
||||
<b-button size="is-small" type="is-light" icon-left="arrow-left" icon-pack="fas" @click="router.back()">Back</b-button>
|
||||
</div>
|
||||
|
||||
<p class="is-size-7 has-text-grey mb-3">
|
||||
Source: <strong>{{ source }}</strong>
|
||||
<span v-if="routeQueryText"> | Query context: "{{ routeQueryText }}"</span>
|
||||
</p>
|
||||
|
||||
<p v-if="error" class="help is-danger mb-3">{{ error }}</p>
|
||||
|
||||
<div v-if="loading" class="box">
|
||||
<p class="is-size-7 mb-0">Loading record...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="record && viewModel" class="box p-4">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-start mb-2">
|
||||
<p class="is-size-6 has-text-weight-semibold mb-0">{{ viewModel.title }}</p>
|
||||
<b-tag type="is-info" size="is-small" rounded>{{ humanizeEntityType(record.entity_type) }}</b-tag>
|
||||
</div>
|
||||
|
||||
<p v-if="viewModel.description" class="is-size-7 has-text-grey-dark mb-2">{{ viewModel.description }}</p>
|
||||
|
||||
<div class="record-badges mb-2">
|
||||
<b-tag
|
||||
v-for="badge in viewModel.badges"
|
||||
:key="`badge-${badge.label}`"
|
||||
:type="badge.type"
|
||||
size="is-small"
|
||||
rounded
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</b-tag>
|
||||
</div>
|
||||
|
||||
<div v-if="viewModel.keyFields.length" class="mb-3">
|
||||
<p v-for="field in viewModel.keyFields" :key="`field-${field.label}`" class="is-size-7 mb-1">
|
||||
<strong>{{ field.label }}:</strong> {{ field.value }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="quickLinks.length" class="mb-3">
|
||||
<p class="menu-label mb-2">Quick Links</p>
|
||||
<b-button
|
||||
v-for="link in quickLinks"
|
||||
:key="`link-${link.path}`"
|
||||
size="is-small"
|
||||
type="is-light"
|
||||
class="mr-2 mb-2"
|
||||
@click="openQuickLink(link.path)"
|
||||
>
|
||||
{{ link.label }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary class="is-size-7 has-text-weight-semibold">Payload inspector</summary>
|
||||
<pre class="payload-pre mt-2">{{ asJson(viewModel.parsedPayload ?? viewModel.rawPayload) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div v-else class="box">
|
||||
<p class="is-size-7 mb-0">No record loaded.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.record-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.payload-pre {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: #f6f6f6;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
aggregateDepartmentVisits,
|
||||
aggregateOrderFrequency,
|
||||
aggregateOrderItemFrequency,
|
||||
buildOverviewStats,
|
||||
buildUsageLogQuery,
|
||||
buildVehiclesQuery,
|
||||
normalizeListResponse,
|
||||
normalizeRegistrationNumber,
|
||||
normalizeRelatedOrders,
|
||||
toIsoDateTimeWithoutTimezone,
|
||||
uniqueCustomerIdsFromUsageLogs,
|
||||
} from '@/views/dashboards/superUserDashboard/vehicle/imports/vehicleViewUtils.js';
|
||||
|
||||
describe('vehicle view utility query builders', () => {
|
||||
it('builds usage log query with OpenAPI pagination params and optional filters', () => {
|
||||
const date = new Date(2026, 1, 14, 9, 30, 15, 123);
|
||||
const result = buildUsageLogQuery({
|
||||
dateFrom: date,
|
||||
regNr: 'AB12345',
|
||||
customerId: '5001',
|
||||
vehicleId: 'vehicle-guid',
|
||||
page: 3,
|
||||
perPage: 55,
|
||||
});
|
||||
|
||||
expect(result.page).toBe(3);
|
||||
expect(result.per_page).toBe(55);
|
||||
expect(result.perPage).toBe(55);
|
||||
expect(result.regNr).toBe('AB12345');
|
||||
expect(result.customerId).toBe('5001');
|
||||
expect(result.vehicleId).toBe('vehicle-guid');
|
||||
expect(result.dateFrom).toBe('2026-02-14T09:30:15.123');
|
||||
});
|
||||
|
||||
it('builds vehicles query with reg/customer/id and OpenAPI pagination params', () => {
|
||||
const result = buildVehiclesQuery({
|
||||
id: 7,
|
||||
reg: 'CD67890',
|
||||
customerId: 9999,
|
||||
page: 2,
|
||||
perPage: 40,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 7,
|
||||
reg: 'CD67890',
|
||||
customer_id: 9999,
|
||||
page: 2,
|
||||
per_page: 40,
|
||||
perPage: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('vehicle view utility normalization', () => {
|
||||
it('normalizes registration number safely and uppercases', () => {
|
||||
expect(normalizeRegistrationNumber('ab 123')).toBe('AB 123');
|
||||
expect(normalizeRegistrationNumber('AB%20123')).toBe('AB 123');
|
||||
expect(normalizeRegistrationNumber(null)).toBe('');
|
||||
});
|
||||
|
||||
it('formats date without timezone in expected precision', () => {
|
||||
const value = toIsoDateTimeWithoutTimezone(new Date(2026, 0, 5, 6, 7, 8, 9));
|
||||
expect(value).toBe('2026-01-05T06:07:08.009');
|
||||
});
|
||||
|
||||
it('normalizes API list shapes', () => {
|
||||
expect(normalizeListResponse({ data: { data: [{ id: 1 }] } })).toEqual([{ id: 1 }]);
|
||||
expect(normalizeListResponse({ data: [{ id: 2 }] })).toEqual([{ id: 2 }]);
|
||||
expect(normalizeListResponse({ data: { data: { id: 3 } } })).toEqual([{ id: 3 }]);
|
||||
expect(normalizeListResponse(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes related order maps', () => {
|
||||
expect(normalizeRelatedOrders({ data: { data: { washA: [1, 2] } } })).toEqual({ washA: [1, 2] });
|
||||
expect(normalizeRelatedOrders({ data: { data: [] } })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('vehicle view analytics aggregations', () => {
|
||||
const usageLogs = [
|
||||
{
|
||||
WashId: 'wash-1',
|
||||
StartTime: '2026-03-01T08:00:00.000Z',
|
||||
CustomerId: '1001',
|
||||
Location: 'Hvidovre',
|
||||
WashItems: [
|
||||
{ OriginalProductName: 'Top Wash', Count: 1 },
|
||||
{ OriginalProductName: 'Spot Free', Count: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
WashId: 'wash-2',
|
||||
StartTime: '2026-03-01T09:30:00.000Z',
|
||||
CustomerId: '1002',
|
||||
Location: 'Hvidovre',
|
||||
WashItems: [
|
||||
{ OriginalProductName: 'Top Wash', Count: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
WashId: 'wash-3',
|
||||
StartTime: '2026-03-02T12:30:00.000Z',
|
||||
CustomerId: '1001',
|
||||
Location: 'Taastrup',
|
||||
WashItems: [
|
||||
{ OriginalProductName: 'Chassis', Count: 3 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const relatedOrders = {
|
||||
'wash-1': [5001],
|
||||
'wash-2': [5002, 5003],
|
||||
'wash-3': [],
|
||||
};
|
||||
|
||||
it('aggregates order frequency by day', () => {
|
||||
const result = aggregateOrderFrequency(usageLogs, relatedOrders);
|
||||
expect(result).toEqual([
|
||||
{ label: '2026-03-01', value: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('aggregates order item frequency with ranking', () => {
|
||||
const result = aggregateOrderItemFrequency(usageLogs, 5);
|
||||
expect(result).toEqual([
|
||||
{ label: 'Chassis', value: 3 },
|
||||
{ label: 'Spot Free', value: 2 },
|
||||
{ label: 'Top Wash', value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('aggregates department visits', () => {
|
||||
const result = aggregateDepartmentVisits(usageLogs);
|
||||
expect(result).toEqual([
|
||||
{ label: 'Hvidovre', value: 2 },
|
||||
{ label: 'Taastrup', value: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts related unique customer ids', () => {
|
||||
expect(uniqueCustomerIdsFromUsageLogs(usageLogs).sort()).toEqual(['1001', '1002']);
|
||||
});
|
||||
|
||||
it('builds overview statistics from usage logs and related orders', () => {
|
||||
const result = buildOverviewStats(usageLogs, relatedOrders);
|
||||
expect(result.totalWashes).toBe(3);
|
||||
expect(result.uniqueCustomers).toBe(2);
|
||||
expect(result.linkedOrders).toBe(3);
|
||||
expect(result.uniqueDepartments).toBe(2);
|
||||
expect(result.latestWashAt).toContain('2026-03-02');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const root = process.cwd();
|
||||
const routerSource = readFileSync(join(root, 'src/router.js'), 'utf8');
|
||||
const vehicleViewSource = readFileSync(
|
||||
join(root, 'src/views/dashboards/superUserDashboard/vehicle/Vehicle.vue'),
|
||||
'utf8'
|
||||
);
|
||||
const vehicleUtilsSource = readFileSync(
|
||||
join(root, 'src/views/dashboards/superUserDashboard/vehicle/imports/vehicleViewUtils.js'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
describe('superuser vehicle route contract', () => {
|
||||
it('maps vehicle details route to registrationnumber path parameter', () => {
|
||||
expect(routerSource).toContain("name: 'vehiclesvehicle'");
|
||||
expect(routerSource).toContain("path: '/superuser/vehicles/:registrationnumber'");
|
||||
expect(routerSource).toContain('component: Vehicle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('superuser vehicle tabbed view contract', () => {
|
||||
it('renders all required tabs using buefy tabs', () => {
|
||||
expect(vehicleViewSource).toContain('<b-tabs');
|
||||
expect(vehicleViewSource).toContain('label="Overview"');
|
||||
expect(vehicleViewSource).toContain('label="Washes"');
|
||||
expect(vehicleViewSource).toContain('label="Customers"');
|
||||
expect(vehicleViewSource).toContain('label="Vehicle details"');
|
||||
expect(vehicleViewSource).toContain('label="Diagrams"');
|
||||
});
|
||||
|
||||
it('uses OpenAPI-aligned endpoints for vehicle intelligence data', () => {
|
||||
expect(vehicleViewSource).toContain("'/vehicles'");
|
||||
expect(vehicleViewSource).toContain("'/vehicles/status'");
|
||||
expect(vehicleViewSource).toContain("'/vehicles/search'");
|
||||
expect(vehicleViewSource).toContain("'/modules/xlvask/usageLog'");
|
||||
expect(vehicleViewSource).toContain("'/modules/xlvask/vehicles'");
|
||||
expect(vehicleViewSource).toContain("'/modules/xlvask/customers'");
|
||||
expect(vehicleViewSource).toContain("'/superuser/users-with-vehicle-subscriptions'");
|
||||
});
|
||||
|
||||
it('includes buefy table/filter controls across washes/customers/details', () => {
|
||||
expect(vehicleViewSource).toContain('<b-table');
|
||||
expect(vehicleViewSource).toContain('<b-field');
|
||||
expect(vehicleViewSource).toContain('<b-input');
|
||||
expect(vehicleViewSource).toContain('<b-select');
|
||||
expect(vehicleViewSource).toContain('Apply filters');
|
||||
expect(vehicleViewSource).toContain('Refresh details');
|
||||
});
|
||||
|
||||
it('renders all requested diagrams', () => {
|
||||
expect(vehicleViewSource).toContain('title="Order frequency"');
|
||||
expect(vehicleViewSource).toContain('title="Order item frequency"');
|
||||
expect(vehicleViewSource).toContain('title="Department visits"');
|
||||
expect(vehicleViewSource).toContain('<VehicleAnalyticsChart');
|
||||
});
|
||||
});
|
||||
|
||||
describe('superuser vehicle openapi filter/options contract', () => {
|
||||
it('builds usage log and vehicles query objects with page/per_page and filters', () => {
|
||||
expect(vehicleUtilsSource).toContain('buildUsageLogQuery');
|
||||
expect(vehicleUtilsSource).toContain('buildVehiclesQuery');
|
||||
expect(vehicleUtilsSource).toContain('per_page');
|
||||
expect(vehicleUtilsSource).toContain('perPage');
|
||||
expect(vehicleUtilsSource).toContain('page');
|
||||
expect(vehicleUtilsSource).toContain('customer_id');
|
||||
expect(vehicleUtilsSource).toContain('regNr');
|
||||
expect(vehicleUtilsSource).toContain('vehicleId');
|
||||
expect(vehicleUtilsSource).toContain('customerId');
|
||||
expect(vehicleUtilsSource).toContain('dateFrom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const root = process.cwd();
|
||||
const modalSource = readFileSync(
|
||||
join(root, 'src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue'),
|
||||
'utf8'
|
||||
);
|
||||
const supportSource = readFileSync(
|
||||
join(root, 'src/components/viewport/page/headers/menu/systemSearchSupport.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
describe('advanced system search modal UI contract', () => {
|
||||
it('uses support registry to build typed cards and open targets', () => {
|
||||
expect(modalSource).toContain('buildSystemSearchViewModel');
|
||||
expect(modalSource).toContain('resolveSystemSearchNavigationTarget');
|
||||
expect(modalSource).toContain('const visibleResultCards = computed');
|
||||
expect(modalSource).toContain('v-for="card in visibleResultCards"');
|
||||
expect(modalSource).toContain('card.viewModel.keyFields');
|
||||
expect(modalSource).toContain('@click="openResult(card)"');
|
||||
expect(modalSource).not.toContain('const resolvePath =');
|
||||
});
|
||||
|
||||
it('renders null-safe payload inspector and badge/key-field structure', () => {
|
||||
expect(modalSource).toContain('card.viewModel.parsedPayload ?? card.viewModel.rawPayload');
|
||||
expect(modalSource).toContain('result-badges');
|
||||
expect(modalSource).toContain('result-key-fields');
|
||||
expect(supportSource).toContain('export const normalizePayload');
|
||||
});
|
||||
|
||||
it('sends include/exclude type filters as arrays for both GET and POST requests', () => {
|
||||
expect(modalSource).toContain('include_types?: EntityType[]');
|
||||
expect(modalSource).toContain('exclude_types?: EntityType[]');
|
||||
expect(modalSource).toContain('params.include_types = [...includeTypes.value]');
|
||||
expect(modalSource).toContain('params.exclude_types = [...excludeTypes.value]');
|
||||
expect(modalSource).toContain('body.include_types = [...includeTypes.value]');
|
||||
expect(modalSource).toContain('body.exclude_types = [...excludeTypes.value]');
|
||||
expect(modalSource).not.toContain("params.include_types = includeTypes.value.join(',')");
|
||||
expect(modalSource).not.toContain("params.exclude_types = excludeTypes.value.join(',')");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const root = process.cwd();
|
||||
const routerSource = readFileSync(join(root, 'src/router.js'), 'utf8');
|
||||
const recordPageSource = readFileSync(join(root, 'src/views/search/SystemSearchRecordPage.vue'), 'utf8');
|
||||
|
||||
describe('system search generic record route contract', () => {
|
||||
it('registers authenticated generic record route in router', () => {
|
||||
expect(routerSource).toContain("import SystemSearchRecordPage from \"@/views/search/SystemSearchRecordPage.vue\";");
|
||||
expect(routerSource).toContain("name: 'systemsearchrecord'");
|
||||
expect(routerSource).toContain("path: '/search/system/record/:entityType/:entityId'");
|
||||
expect(routerSource).toContain('component: SystemSearchRecordPage');
|
||||
expect(routerSource).toContain('meta: { middleware: authMiddleware }');
|
||||
});
|
||||
});
|
||||
|
||||
describe('system search generic record page lookup contract', () => {
|
||||
it('hydrates from state and falls back to /search/system lookup', () => {
|
||||
expect(recordPageSource).toContain('systemSearchResult');
|
||||
expect(recordPageSource).toContain("SessionUser.request('/search/system', 'POST', body)");
|
||||
expect(recordPageSource).toContain('include_types: [entityType.value]');
|
||||
expect(recordPageSource).toContain('allowGenericFallback: false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SYSTEM_SEARCH_ENTITY_TYPES,
|
||||
SYSTEM_SEARCH_SUPPORT_REGISTRY,
|
||||
buildSystemSearchViewModel,
|
||||
resolveSystemSearchNavigationTarget,
|
||||
} from '@/components/viewport/page/headers/menu/systemSearchSupport.ts';
|
||||
|
||||
const superuserContext = {
|
||||
canAccessSuperUser: true,
|
||||
canAccessAdmin: true,
|
||||
canAccessUser: true,
|
||||
canAccessDepartment: () => true,
|
||||
};
|
||||
|
||||
const adminContext = {
|
||||
canAccessSuperUser: false,
|
||||
canAccessAdmin: true,
|
||||
canAccessUser: false,
|
||||
canAccessDepartment: (departmentId) => departmentId === 4,
|
||||
};
|
||||
|
||||
const userContext = {
|
||||
canAccessSuperUser: false,
|
||||
canAccessAdmin: false,
|
||||
canAccessUser: true,
|
||||
canAccessDepartment: () => false,
|
||||
};
|
||||
|
||||
const row = (entityType, overrides = {}) => ({
|
||||
entity_type: entityType,
|
||||
entity_id: '11',
|
||||
title: '11',
|
||||
score: 120,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('system search support registry coverage', () => {
|
||||
it('contains exactly all 58 known entity types', () => {
|
||||
expect(SYSTEM_SEARCH_ENTITY_TYPES).toHaveLength(58);
|
||||
expect(new Set(SYSTEM_SEARCH_ENTITY_TYPES).size).toBe(58);
|
||||
|
||||
const registryKeys = Object.keys(SYSTEM_SEARCH_SUPPORT_REGISTRY).sort();
|
||||
const entityKeys = [...SYSTEM_SEARCH_ENTITY_TYPES].sort();
|
||||
expect(registryKeys).toEqual(entityKeys);
|
||||
});
|
||||
|
||||
it('provides render and navigation strategy for every type', () => {
|
||||
for (const entityType of SYSTEM_SEARCH_ENTITY_TYPES) {
|
||||
const definition = SYSTEM_SEARCH_SUPPORT_REGISTRY[entityType];
|
||||
expect(definition).toBeTruthy();
|
||||
expect(['deep-link', 'module', 'generic']).toContain(definition.navigationStrategy);
|
||||
expect(definition.titleKeys.length).toBeGreaterThan(0);
|
||||
expect(definition.descriptionKeys.length).toBeGreaterThan(0);
|
||||
expect(definition.keyFieldSelectors.length).toBeGreaterThan(0);
|
||||
|
||||
const vm = buildSystemSearchViewModel(row(entityType, { payload: null }));
|
||||
expect(typeof vm.title).toBe('string');
|
||||
expect(Array.isArray(vm.badges)).toBe(true);
|
||||
expect(Array.isArray(vm.keyFields)).toBe(true);
|
||||
|
||||
const nav = resolveSystemSearchNavigationTarget(row(entityType), superuserContext);
|
||||
expect(nav).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('system search navigation resolver behavior', () => {
|
||||
it('deep-links order details for admin/superuser with department context', () => {
|
||||
const nav = resolveSystemSearchNavigationTarget(
|
||||
row('orders', {
|
||||
entity_id: '34455',
|
||||
payload: { id: 34455, department: '4' },
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
expect(nav.strategy).toBe('deep-link');
|
||||
expect(nav.to.path).toBe('/admin/4/modules/pos/orders/34455');
|
||||
});
|
||||
|
||||
it('opens user order detail for user role', () => {
|
||||
const nav = resolveSystemSearchNavigationTarget(
|
||||
row('orders', { entity_id: '6078', payload: { id: 6078 } }),
|
||||
userContext
|
||||
);
|
||||
expect(nav.to.path).toBe('/user/orders/6078');
|
||||
});
|
||||
|
||||
it('falls back to generic detail when bookings are missing department context', () => {
|
||||
const nav = resolveSystemSearchNavigationTarget(
|
||||
row('bookings', { payload: null }),
|
||||
adminContext
|
||||
);
|
||||
expect(nav.strategy).toBe('generic');
|
||||
expect(nav.to.path).toContain('/search/system/record/bookings/11');
|
||||
});
|
||||
|
||||
it('routes department self-serve entities to module pages when department is known', () => {
|
||||
const nav = resolveSystemSearchNavigationTarget(
|
||||
row('department_selfserve_questions', {
|
||||
payload: { id: '19', department: '4' },
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
expect(nav.strategy).toBe('module');
|
||||
expect(nav.to.path).toBe('/admin/4/modules/self-serve/questions');
|
||||
});
|
||||
|
||||
it('routes customers and vehicles based on user role context', () => {
|
||||
const customerForSuper = resolveSystemSearchNavigationTarget(row('customers'), superuserContext);
|
||||
const customerForUser = resolveSystemSearchNavigationTarget(row('customers'), userContext);
|
||||
const vehicleForSuper = resolveSystemSearchNavigationTarget(
|
||||
row('vehicles', { payload: { registration_number: 'AB12345' } }),
|
||||
superuserContext
|
||||
);
|
||||
const vehicleForUser = resolveSystemSearchNavigationTarget(
|
||||
row('vehicles', { payload: { registration_number: 'AB12345' } }),
|
||||
userContext
|
||||
);
|
||||
|
||||
expect(customerForSuper.to.path).toBe('/superuser/customers');
|
||||
expect(customerForUser.to.path).toBe('/user/profile');
|
||||
expect(vehicleForSuper.to.path).toBe('/superuser/vehicles/AB12345');
|
||||
expect(vehicleForUser.to.path).toBe('/user/vehicles');
|
||||
});
|
||||
|
||||
it('routes xlvask and config/module types to their module pages with superuser access', () => {
|
||||
const usage = resolveSystemSearchNavigationTarget(row('xlvask_usage_logs'), superuserContext);
|
||||
const moduleConfig = resolveSystemSearchNavigationTarget(row('module_config'), superuserContext);
|
||||
const fxrates = resolveSystemSearchNavigationTarget(row('fxratesapi_conversion_rates'), superuserContext);
|
||||
|
||||
expect(usage.to.path).toBe('/superuser/xlvask/usagelogs');
|
||||
expect(moduleConfig.to.path).toBe('/superuser/configuration');
|
||||
expect(fxrates.to.path).toBe('/superuser/configuration/fxratesapi');
|
||||
});
|
||||
|
||||
it('handles missing payload safely and still opens generic detail', () => {
|
||||
const nav = resolveSystemSearchNavigationTarget(
|
||||
row('module_action_logs', { payload: null }),
|
||||
userContext
|
||||
);
|
||||
expect(nav.strategy).toBe('generic');
|
||||
expect(nav.to.path).toContain('/search/system/record/module_action_logs/11');
|
||||
});
|
||||
});
|
||||
|
||||
describe('system search card view-model behavior', () => {
|
||||
it('creates readable key-field summaries for json-like payload strings', () => {
|
||||
const vm = buildSystemSearchViewModel(row('order_bookings', {
|
||||
payload: {
|
||||
items: '[{\"id\": 10, \"name\": \"Indvendig vask Trailer\"}, {\"id\": 41, \"name\": \"Safety Seal\"}]',
|
||||
datetime: '2025-11-24 23:00:00',
|
||||
reg_1: 'FC2861',
|
||||
},
|
||||
}));
|
||||
|
||||
const itemsField = vm.keyFields.find((entry) => entry.label === 'Items');
|
||||
expect(itemsField.value).toContain('2 items');
|
||||
expect(vm.parsedPayload.items).toBeTypeOf('object');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user