Merge branch 'master' of https://github.com/copenhagentruckwash/pleno-vue
This commit is contained in:
+2318
-23
File diff suppressed because it is too large
Load Diff
@@ -392,6 +392,103 @@ export const CollectedOrderInvoices = {
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
v2: {
|
||||
details: async (collectedInvoiceId) => {
|
||||
return authenticatedRequest('/collected-invoices/economic/v2/details', 'GET', {
|
||||
collected_invoice_id: parseInt(collectedInvoiceId),
|
||||
}).then((response) => {
|
||||
console.warn(response);
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
console.warn(error);
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
compare: async (collectedInvoiceId) => {
|
||||
return authenticatedRequest('/collected-invoices/economic/v2/compare', 'GET', {
|
||||
collected_invoice_id: parseInt(collectedInvoiceId),
|
||||
}).then((response) => {
|
||||
console.warn(response);
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
console.warn(error);
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
compareBulk: async (collectedInvoiceIds = []) => {
|
||||
const MAX_BULK_COMPARE_IDS = 200;
|
||||
const sanitizedIds = (Array.isArray(collectedInvoiceIds) ? collectedInvoiceIds : [])
|
||||
.map((id) => parseInt(id))
|
||||
.filter((id) => Number.isInteger(id) && id > 0);
|
||||
|
||||
if (!sanitizedIds.length) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
requested: 0,
|
||||
compared: 0,
|
||||
failed: 0,
|
||||
results: [],
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (sanitizedIds.length <= MAX_BULK_COMPARE_IDS) {
|
||||
return authenticatedRequest('/collected-invoices/economic/v2/compare/bulk', 'POST', {
|
||||
collected_invoice_ids: sanitizedIds,
|
||||
}).then((response) => {
|
||||
console.warn(response);
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
console.warn(error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
const aggregated = {
|
||||
requested: sanitizedIds.length,
|
||||
compared: 0,
|
||||
failed: 0,
|
||||
results: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < sanitizedIds.length; index += MAX_BULK_COMPARE_IDS) {
|
||||
const chunkIds = sanitizedIds.slice(index, index + MAX_BULK_COMPARE_IDS);
|
||||
const response = await authenticatedRequest('/collected-invoices/economic/v2/compare/bulk', 'POST', {
|
||||
collected_invoice_ids: chunkIds,
|
||||
});
|
||||
const payload = response?.data?.data || response?.data || {};
|
||||
const results = Array.isArray(payload?.results) ? payload.results : [];
|
||||
const errors = Array.isArray(payload?.errors) ? payload.errors : [];
|
||||
const comparedCount = Number(payload?.compared);
|
||||
const failedCount = Number(payload?.failed);
|
||||
|
||||
aggregated.compared += Number.isFinite(comparedCount) ? comparedCount : results.length;
|
||||
aggregated.failed += Number.isFinite(failedCount) ? failedCount : errors.length;
|
||||
aggregated.results.push(...results);
|
||||
aggregated.errors.push(...errors);
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: aggregated,
|
||||
},
|
||||
};
|
||||
},
|
||||
revenueStatistics: async (params = {}) => {
|
||||
return authenticatedRequest('/collected-invoices/economic/v2/revenue-statistics', 'GET', params)
|
||||
.then((response) => {
|
||||
console.warn(response);
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
console.warn(error);
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
},
|
||||
// The additionalBody can contain:
|
||||
// - send_as_is: boolean (default: false) - If true, the invoice will be sent to E-conomic as is, without any modifications made by fixed pricing or vehicle subscriptions.
|
||||
invoice: async (invoiceId, additionalBody = {
|
||||
@@ -511,4 +608,4 @@ export const CollectedOrderInvoices = {
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNavigationItems } from "@/components/models/navigation/items/Navigat
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue";
|
||||
const { parsedItems, parsedItemsGlobal } = useNavigationItems();
|
||||
const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || 'No build date';
|
||||
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || 'No commit hash';
|
||||
@@ -61,6 +62,9 @@ const visibleChildren = (item: any) => {
|
||||
<template>
|
||||
<div class="section pl-3 pr-1 pt-0">
|
||||
<b-menu ref="menuList">
|
||||
<div class="mb-4">
|
||||
<NavigationMenuGlobalSearch />
|
||||
</div>
|
||||
<b-menu-list label="">
|
||||
<template
|
||||
v-for="(item, index) in [...parsedItems(), ...parsedItemsGlobal()]"
|
||||
@@ -140,4 +144,4 @@ export default defineComponent({
|
||||
.transform-black {
|
||||
filter: invert(1) grayscale(1) brightness(0);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1063
-784
File diff suppressed because it is too large
Load Diff
+2753
-2475
File diff suppressed because it is too large
Load Diff
+1496
-1218
File diff suppressed because it is too large
Load Diff
+2448
-2170
File diff suppressed because it is too large
Load Diff
+1304
-1026
File diff suppressed because it is too large
Load Diff
+22
-1
@@ -79,6 +79,8 @@ import DepartmentStripeSetup from "@/views/dashboards/superUserDashboard/departm
|
||||
import CollectedOrderInvoices from "@/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue";
|
||||
import CollectedOrderInvoice
|
||||
from "@/views/dashboards/superUserDashboard/collectedOrderInvoice/collectedOrderInvoice.vue";
|
||||
import InvoiceDistributionMonthView
|
||||
from "@/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue";
|
||||
import DepartmentDailyReport
|
||||
from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue";
|
||||
import SuperUserDashboardProduct from "@/views/dashboards/superUserDashboard/products/SuperUserDashboardProduct.vue";
|
||||
@@ -166,6 +168,7 @@ import CustomerCreationPage from "@/views/pages/auth/CustomerCreationPage.vue";
|
||||
import Subusers from "@/views/dashboards/superUserDashboard/Subusers.vue";
|
||||
import SubuserGrants from "@/views/dashboards/superUserDashboard/SubuserGrants.vue";
|
||||
import SubuserLogin from "@/views/auth/SubuserLogin.vue";
|
||||
import SystemSearchRecordPage from "@/views/search/SystemSearchRecordPage.vue";
|
||||
|
||||
/**
|
||||
* Meta data for routes
|
||||
@@ -224,6 +227,12 @@ export const router = createRouter({
|
||||
component: DefaultPage,
|
||||
meta: { middleware: authMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'systemsearchrecord',
|
||||
path: '/search/system/record/:entityType/:entityId',
|
||||
component: SystemSearchRecordPage,
|
||||
meta: { middleware: authMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'callbackMicrosoftToken',
|
||||
path: '/callback/microsoft/token',
|
||||
@@ -689,6 +698,18 @@ export const router = createRouter({
|
||||
component: CollectedOrderInvoices,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'collectedorderinvoicesdistribution',
|
||||
path: '/superuser/invoices/distribution/:year/:month',
|
||||
component: InvoiceDistributionMonthView,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'collectedorderinvoicesdistributiontab',
|
||||
path: '/superuser/invoices/distribution/:year/:month/:tab',
|
||||
component: InvoiceDistributionMonthView,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'collectedorderinvoice',
|
||||
path: '/superuser/invoices/:collectedOrderInvoiceId',
|
||||
@@ -890,7 +911,7 @@ export const router = createRouter({
|
||||
},
|
||||
{
|
||||
name: 'vehiclesvehicle',
|
||||
path: '/superuser/vehicles/:reg',
|
||||
path: '/superuser/vehicles/:registrationnumber',
|
||||
component: Vehicle,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
|
||||
@@ -12,39 +12,40 @@ import InvoiceOrdersPagination
|
||||
import {useRouter} from "vue-router";
|
||||
import InvoicingBillingPeriod
|
||||
from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/InvoicingBillingPeriod.vue";
|
||||
import InvoiceDistributionOverview
|
||||
from "@/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tabs = ref([
|
||||
{ name: 'Overblik', slot: 'overview', icon: 'chart-pie', hidden: false },
|
||||
{ name: 'Fakturaer', slot: 'invoices', icon: 'list', hidden: false },
|
||||
//{ name: SessionUser.objects.orders.meta.title, slot: 'transactions', icon: 'fas fa-shopping-cart', hidden: false },
|
||||
{ name: 'Periode', slot: 'period', icon: 'calendar-alt', hidden: false },
|
||||
//{ name: 'Faktura pr. måned', slot: 'monthly', icon: 'fas fa-list', hidden: false },
|
||||
//{ name: 'Faktura pr. ordre', slot: 'orderly', icon: 'fas fa-check', hidden: false }
|
||||
{ name: t('superuser_invoice_distribution.tab_label'), slot: 'distribution', icon: 'sitemap', hidden: false },
|
||||
])
|
||||
|
||||
const router = useRouter();
|
||||
// Initialize the active tab
|
||||
const activeTab = ref('overview');
|
||||
// Watch for changes in the route to set the query parameter
|
||||
|
||||
watch(() => router.currentRoute.value.query.activeTab, (newTab) => {
|
||||
if (newTab && tabs.value.some(tab => tab.slot === newTab)) {
|
||||
activeTab.value = newTab;
|
||||
}
|
||||
});
|
||||
// Watch for changes in the active tab and update the query parameter
|
||||
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab && router.currentRoute.value.query.activeTab !== newTab) {
|
||||
router.replace({ query: { ...router.currentRoute.value.query, activeTab: newTab } });
|
||||
}
|
||||
});
|
||||
|
||||
// Set the initial active tab based on the query parameter
|
||||
onMounted(() => {
|
||||
const queryTab = router.currentRoute.value.query.activeTab;
|
||||
if (queryTab && tabs.value.some(tab => tab.slot === queryTab)) {
|
||||
activeTab.value = queryTab;
|
||||
} else {
|
||||
activeTab.value = 'overview'; // Default to overview if no valid tab is found
|
||||
activeTab.value = 'overview';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,10 +59,8 @@ function getTabComponent(tabName) {
|
||||
return InvoiceOrdersPagination;
|
||||
case 'period':
|
||||
return InvoicingBillingPeriod;
|
||||
//case 'monthly':
|
||||
// return MonthlyInvoiceStatistics;
|
||||
//case 'orderly':
|
||||
// return OrderlyInvoiceStatistics;
|
||||
case 'distribution':
|
||||
return InvoiceDistributionOverview;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
+1318
File diff suppressed because it is too large
Load Diff
+603
@@ -0,0 +1,603 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import InvoiceDistributionTrendChart from '@/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionTrendChart.vue';
|
||||
import InvoiceDistributionSkeleton from '@/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionSkeleton.vue';
|
||||
import {
|
||||
fetchDistributionOverviewMonths,
|
||||
fetchFirstOrderDate,
|
||||
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js';
|
||||
import {
|
||||
buildSourceComposition,
|
||||
getYearMonthLabel,
|
||||
sortMonthSummaries,
|
||||
toNumber,
|
||||
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const monthSummaries = ref([]);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const loadProgress = ref({ loaded: 0, total: 0 });
|
||||
|
||||
const search = ref('');
|
||||
const yearFilter = ref('all');
|
||||
const sortKey = ref('month');
|
||||
const sortDirection = ref('desc');
|
||||
|
||||
const availableYears = computed(() => {
|
||||
const years = new Set(monthSummaries.value.map((summary) => String(summary.year)));
|
||||
return ['all', ...Array.from(years).sort((a, b) => Number(b) - Number(a))];
|
||||
});
|
||||
|
||||
const filteredMonthSummaries = computed(() => {
|
||||
const normalizedSearch = String(search.value || '').trim().toLowerCase();
|
||||
return monthSummaries.value.filter((summary) => {
|
||||
const monthLabel = getYearMonthLabel(summary.year, summary.month, locale.value);
|
||||
const searchMatch = !normalizedSearch || monthLabel.toLowerCase().includes(normalizedSearch);
|
||||
const yearMatch = yearFilter.value === 'all' || String(summary.year) === yearFilter.value;
|
||||
return searchMatch && yearMatch;
|
||||
});
|
||||
});
|
||||
|
||||
const sortedMonthSummaries = computed(() => {
|
||||
return sortMonthSummaries(filteredMonthSummaries.value, sortKey.value, sortDirection.value);
|
||||
});
|
||||
|
||||
const totals = computed(() => {
|
||||
return sortedMonthSummaries.value.reduce((acc, summary) => {
|
||||
acc.bookedAmount += toNumber(summary.bookedAmount);
|
||||
acc.distributionAmount += toNumber(summary.distributionAmount);
|
||||
acc.fixedPricingAmount += toNumber(summary.fixedPricingAmount);
|
||||
acc.subscriptionAmount += toNumber(summary.subscriptionAmount);
|
||||
acc.customerPriceAmount += toNumber(summary.customerPriceAmount);
|
||||
acc.totalAmount += toNumber(summary.totalAmount);
|
||||
return acc;
|
||||
}, {
|
||||
bookedAmount: 0,
|
||||
distributionAmount: 0,
|
||||
fixedPricingAmount: 0,
|
||||
subscriptionAmount: 0,
|
||||
customerPriceAmount: 0,
|
||||
totalAmount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const totalComposition = computed(() => buildSourceComposition(totals.value));
|
||||
const monthsUsingLegacyDistribution = computed(() => {
|
||||
return sortedMonthSummaries.value.filter((summary) => summary?.fallback?.usedLegacyDistribution);
|
||||
});
|
||||
const latestDistributionFallbackReason = computed(() => {
|
||||
const last = monthsUsingLegacyDistribution.value[0];
|
||||
return last?.fallback?.distributionFallbackReason || '';
|
||||
});
|
||||
|
||||
const topMonths = computed(() => {
|
||||
return sortMonthSummaries(sortedMonthSummaries.value, 'total', 'desc').slice(0, 3);
|
||||
});
|
||||
|
||||
const formatCurrency = (value) => SessionUser.functions.currency.toLocal(toNumber(value));
|
||||
const formatPercent = (value) => `${Math.round(toNumber(value))}%`;
|
||||
|
||||
const getMonthLabel = (summary) => {
|
||||
return getYearMonthLabel(summary.year, summary.month, locale.value);
|
||||
};
|
||||
|
||||
const getSortIcon = (column) => {
|
||||
if (sortKey.value !== column) {
|
||||
return 'fa-sort';
|
||||
}
|
||||
return sortDirection.value === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
|
||||
};
|
||||
|
||||
const getAriaSort = (column) => {
|
||||
if (sortKey.value !== column) {
|
||||
return 'none';
|
||||
}
|
||||
return sortDirection.value === 'asc' ? 'ascending' : 'descending';
|
||||
};
|
||||
|
||||
const setSort = (column) => {
|
||||
if (sortKey.value === column) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
|
||||
return;
|
||||
}
|
||||
sortKey.value = column;
|
||||
sortDirection.value = 'desc';
|
||||
};
|
||||
|
||||
const openMonthDetails = (summary) => {
|
||||
window.open(`/superuser/invoices/distribution/${summary.year}/${summary.month}`, '_blank');
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
search.value = '';
|
||||
yearFilter.value = 'all';
|
||||
sortKey.value = 'month';
|
||||
sortDirection.value = 'desc';
|
||||
};
|
||||
|
||||
const loadOverview = async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
loadProgress.value = { loaded: 0, total: 0 };
|
||||
|
||||
try {
|
||||
const firstOrderDate = await fetchFirstOrderDate();
|
||||
const summaries = await fetchDistributionOverviewMonths({
|
||||
fromDate: firstOrderDate,
|
||||
onProgress: (progress) => {
|
||||
loadProgress.value = progress;
|
||||
},
|
||||
});
|
||||
monthSummaries.value = summaries;
|
||||
} catch (error) {
|
||||
errorMessage.value = SessionUser.functions.parseErrorMessage(error) || String(error?.message || error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="distribution-overview distribution-overview-v2" data-testid="distribution-overview-page">
|
||||
<b-card class="overview-header hero-surface">
|
||||
<div class="overview-header__top hero-row">
|
||||
<div>
|
||||
<p class="eyebrow">Superuser • Revenue Intelligence</p>
|
||||
<h2 class="title is-4 mb-1">{{ t('superuser_invoice_distribution.overview.title') }}</h2>
|
||||
<p class="subtitle is-6 mb-0">{{ t('superuser_invoice_distribution.overview.subtitle') }}</p>
|
||||
</div>
|
||||
<b-button
|
||||
type="is-dark"
|
||||
class="control-button"
|
||||
:loading="loading"
|
||||
:aria-label="t('superuser_invoice_distribution.actions.reload')"
|
||||
@click="loadOverview"
|
||||
icon-pack="fas"
|
||||
icon-left="sync-alt"
|
||||
data-testid="distribution-overview-refresh"
|
||||
>
|
||||
{{ t('superuser_invoice_distribution.actions.reload') }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<div class="control-toolbar mt-4 overview-dock">
|
||||
<b-field :label="t('superuser_invoice_distribution.filters.search')" class="toolbar-field">
|
||||
<b-input
|
||||
v-model="search"
|
||||
:placeholder="t('superuser_invoice_distribution.filters.search_placeholder')"
|
||||
icon="search"
|
||||
icon-pack="fas"
|
||||
:aria-label="t('superuser_invoice_distribution.filters.search')"
|
||||
/>
|
||||
</b-field>
|
||||
<b-field :label="t('superuser_invoice_distribution.filters.year')" class="toolbar-field">
|
||||
<b-select v-model="yearFilter" expanded :aria-label="t('superuser_invoice_distribution.filters.year')">
|
||||
<option v-for="yearOption in availableYears" :key="yearOption" :value="yearOption">
|
||||
{{ yearOption === 'all' ? t('superuser_invoice_distribution.filters.all_years') : yearOption }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<div class="toolbar-hint" role="status" aria-live="polite">
|
||||
<p class="toolbar-hint__title">{{ t('superuser_invoice_distribution.overview.loaded_months', { loaded: sortedMonthSummaries.length }) }}</p>
|
||||
<p class="toolbar-hint__subtitle">{{ t('superuser_invoice_distribution.tooltips.summary') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</b-card>
|
||||
|
||||
<b-message v-if="errorMessage" type="is-danger" has-icon icon-pack="fas" class="mb-0">
|
||||
<div class="error-state__content">
|
||||
<p>{{ t('superuser_invoice_distribution.errors.overview_load_failed') }}: {{ errorMessage }}</p>
|
||||
<b-button type="is-danger" class="control-button is-light" @click="loadOverview">
|
||||
{{ t('superuser_invoice_distribution.actions.try_again') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</b-message>
|
||||
|
||||
<b-message
|
||||
v-if="!loading && monthsUsingLegacyDistribution.length"
|
||||
type="is-warning"
|
||||
has-icon
|
||||
icon-pack="fas"
|
||||
class="mb-0"
|
||||
>
|
||||
<div class="fallback-note">
|
||||
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_fallback', { count: monthsUsingLegacyDistribution.length }) }}</p>
|
||||
<p v-if="latestDistributionFallbackReason" class="is-size-7">{{ latestDistributionFallbackReason }}</p>
|
||||
</div>
|
||||
</b-message>
|
||||
|
||||
<b-card v-if="loading">
|
||||
<div class="progress-meta mb-3">
|
||||
<span>{{ t('superuser_invoice_distribution.loading.loading_months', { loaded: loadProgress.loaded, total: loadProgress.total }) }}</span>
|
||||
</div>
|
||||
<b-progress
|
||||
type="is-primary"
|
||||
size="is-small"
|
||||
:value="loadProgress.loaded"
|
||||
:max="Math.max(loadProgress.total, 1)"
|
||||
aria-label="Loading distribution overview"
|
||||
/>
|
||||
<InvoiceDistributionSkeleton :rows="7" />
|
||||
</b-card>
|
||||
|
||||
<template v-else>
|
||||
<section class="kpi-strip" aria-label="KPI summary">
|
||||
<b-card class="metric-card metric-total">
|
||||
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.total') }}</p>
|
||||
<p class="metric-value">{{ formatCurrency(totals.totalAmount) }}</p>
|
||||
</b-card>
|
||||
<b-card class="metric-card metric-booked">
|
||||
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.booked') }}</p>
|
||||
<p class="metric-value">{{ formatCurrency(totals.bookedAmount) }}</p>
|
||||
</b-card>
|
||||
<b-card class="metric-card metric-distribution">
|
||||
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.distribution') }}</p>
|
||||
<p class="metric-value">{{ formatCurrency(totals.distributionAmount) }}</p>
|
||||
</b-card>
|
||||
<b-card class="metric-card metric-customer-prices">
|
||||
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</p>
|
||||
<p class="metric-value">{{ formatCurrency(totals.customerPriceAmount) }}</p>
|
||||
</b-card>
|
||||
<b-card class="metric-card metric-months">
|
||||
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.months') }}</p>
|
||||
<p class="metric-value">{{ sortedMonthSummaries.length }}</p>
|
||||
</b-card>
|
||||
</section>
|
||||
|
||||
<b-card class="chart-section">
|
||||
<div class="section-header">
|
||||
<h3 class="title is-6 mb-0">{{ t('superuser_invoice_distribution.sections.trend') }}</h3>
|
||||
</div>
|
||||
<InvoiceDistributionTrendChart
|
||||
:months="sortedMonthSummaries"
|
||||
:locale="locale"
|
||||
:aria-label="t('superuser_invoice_distribution.aria.trend_chart')"
|
||||
:labels="{
|
||||
total: t('superuser_invoice_distribution.metrics.total'),
|
||||
booked: t('superuser_invoice_distribution.metrics.booked'),
|
||||
distribution: t('superuser_invoice_distribution.metrics.distribution'),
|
||||
customerPrices: t('superuser_invoice_distribution.metrics.customer_prices'),
|
||||
}"
|
||||
/>
|
||||
</b-card>
|
||||
|
||||
<b-card>
|
||||
<div class="section-header">
|
||||
<h3 class="title is-6 mb-0">{{ t('superuser_invoice_distribution.sections.month_table') }}</h3>
|
||||
<b-button size="is-small" type="is-light" class="control-button" @click="resetFilters">
|
||||
{{ t('superuser_invoice_distribution.actions.reset_filters') }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!sortedMonthSummaries.length" class="empty-state has-text-centered">
|
||||
<p class="has-text-grey mb-3">{{ t('superuser_invoice_distribution.empty.no_months') }}</p>
|
||||
<b-button type="is-link" class="control-button is-light" @click="loadOverview">
|
||||
{{ t('superuser_invoice_distribution.actions.try_again') }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<div class="table-container table-container--scroll" v-else>
|
||||
<table class="table is-fullwidth is-hoverable is-striped distribution-table">
|
||||
<caption>{{ t('superuser_invoice_distribution.aria.month_table_caption') }}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" :aria-sort="getAriaSort('month')">
|
||||
<b-button
|
||||
type="is-text"
|
||||
size="is-small"
|
||||
class="table-sort"
|
||||
@click="setSort('month')"
|
||||
:aria-label="t('superuser_invoice_distribution.aria.sort_month')"
|
||||
>
|
||||
<span>{{ t('superuser_invoice_distribution.table.month') }}</span>
|
||||
<span class="icon is-small"><i class="fas" :class="getSortIcon('month')"></i></span>
|
||||
</b-button>
|
||||
</th>
|
||||
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.booked') }}</th>
|
||||
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.fixed_pricing') }}</th>
|
||||
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.subscriptions') }}</th>
|
||||
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.customer_prices') }}</th>
|
||||
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.distribution') }}</th>
|
||||
<th scope="col" :aria-sort="getAriaSort('total')" class="has-text-right">
|
||||
<b-button
|
||||
type="is-text"
|
||||
size="is-small"
|
||||
class="table-sort table-sort--right"
|
||||
@click="setSort('total')"
|
||||
:aria-label="t('superuser_invoice_distribution.aria.sort_total')"
|
||||
>
|
||||
<span>{{ t('superuser_invoice_distribution.table.total') }}</span>
|
||||
<span class="icon is-small"><i class="fas" :class="getSortIcon('total')"></i></span>
|
||||
</b-button>
|
||||
</th>
|
||||
<th scope="col">{{ t('superuser_invoice_distribution.table.source_mix') }}</th>
|
||||
<th scope="col">{{ t('superuser_invoice_distribution.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="summary in sortedMonthSummaries" :key="summary.key">
|
||||
<th scope="row">{{ getMonthLabel(summary) }}</th>
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.bookedAmount) }}</td>
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.fixedPricingAmount) }}</td>
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.subscriptionAmount) }}</td>
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.customerPriceAmount) }}</td>
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.distributionAmount) }}</td>
|
||||
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.totalAmount) }}</td>
|
||||
<td>
|
||||
<div class="source-composition">
|
||||
<div class="source-composition__bar" :aria-label="t('superuser_invoice_distribution.aria.source_mix_row', { month: getMonthLabel(summary) })">
|
||||
<span
|
||||
class="source-composition__segment source-composition__segment--fixed"
|
||||
:style="{ width: `${buildSourceComposition(summary).fixedPercent}%` }"
|
||||
></span>
|
||||
<span
|
||||
class="source-composition__segment source-composition__segment--subscription"
|
||||
:style="{ width: `${buildSourceComposition(summary).subscriptionPercent}%` }"
|
||||
></span>
|
||||
<span
|
||||
class="source-composition__segment source-composition__segment--customer-prices"
|
||||
:style="{ width: `${buildSourceComposition(summary).customerPricePercent}%` }"
|
||||
></span>
|
||||
</div>
|
||||
<span class="is-size-7 has-text-grey">
|
||||
{{ formatPercent(buildSourceComposition(summary).fixedPercent) }}
|
||||
/
|
||||
{{ formatPercent(buildSourceComposition(summary).subscriptionPercent) }}
|
||||
/
|
||||
{{ formatPercent(buildSourceComposition(summary).customerPricePercent) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<b-button
|
||||
size="is-small"
|
||||
type="is-link"
|
||||
class="control-button is-light"
|
||||
@click="openMonthDetails(summary)"
|
||||
:aria-label="t('superuser_invoice_distribution.aria.open_month', { month: getMonthLabel(summary) })"
|
||||
icon-pack="fas"
|
||||
icon-left="external-link-alt"
|
||||
data-testid="distribution-overview-open-month"
|
||||
>
|
||||
{{ t('superuser_invoice_distribution.actions.open_month') }}
|
||||
</b-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</b-card>
|
||||
|
||||
<section class="insights-grid" aria-label="Secondary insights">
|
||||
<b-card class="insight-card">
|
||||
<h3 class="title is-6 mb-3">{{ t('superuser_invoice_distribution.sections.source_blend') }}</h3>
|
||||
<div class="insight-row">
|
||||
<span>{{ t('superuser_invoice_distribution.metrics.fixed_pricing') }}</span>
|
||||
<strong>{{ formatCurrency(totalComposition.fixedAmount) }} ({{ formatPercent(totalComposition.fixedPercent) }})</strong>
|
||||
</div>
|
||||
<div class="insight-row">
|
||||
<span>{{ t('superuser_invoice_distribution.metrics.subscriptions') }}</span>
|
||||
<strong>{{ formatCurrency(totalComposition.subscriptionAmount) }} ({{ formatPercent(totalComposition.subscriptionPercent) }})</strong>
|
||||
</div>
|
||||
<div class="insight-row">
|
||||
<span>{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</span>
|
||||
<strong>{{ formatCurrency(totalComposition.customerPriceAmount) }} ({{ formatPercent(totalComposition.customerPricePercent) }})</strong>
|
||||
</div>
|
||||
</b-card>
|
||||
|
||||
<b-card class="insight-card">
|
||||
<h3 class="title is-6 mb-3">{{ t('superuser_invoice_distribution.sections.top_months') }}</h3>
|
||||
<div v-if="topMonths.length" class="top-month-list">
|
||||
<b-button
|
||||
v-for="summary in topMonths"
|
||||
:key="summary.key"
|
||||
size="is-small"
|
||||
type="is-light"
|
||||
class="top-month-button control-button"
|
||||
@click="openMonthDetails(summary)"
|
||||
>
|
||||
<span>{{ getMonthLabel(summary) }}</span>
|
||||
<span class="has-text-weight-semibold">{{ formatCurrency(summary.totalAmount) }}</span>
|
||||
</b-button>
|
||||
</div>
|
||||
<p v-else class="has-text-grey">{{ t('superuser_invoice_distribution.empty.no_months') }}</p>
|
||||
</b-card>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:root {
|
||||
--distribution-v2-accent: #0f6b86;
|
||||
}
|
||||
|
||||
:deep(.card) {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.distribution-overview-v2 {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-surface {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(15, 107, 134, 0.2), transparent 55%),
|
||||
linear-gradient(135deg, #f5fbff 0%, #f7f8fc 100%);
|
||||
}
|
||||
|
||||
.hero-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 0.4rem;
|
||||
color: #38576a;
|
||||
}
|
||||
|
||||
.overview-dock {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr minmax(220px, 1fr);
|
||||
gap: 0.9rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.toolbar-hint {
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e5ee;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.toolbar-hint__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbar-hint__subtitle {
|
||||
color: #617986;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.fallback-note p + p {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e5ee;
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
color: #617986;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-customer-prices {
|
||||
border-color: #f0d6c8;
|
||||
background: #fffaf7;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
border: 1px solid #d8e5ee;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.distribution-table {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.source-composition__bar {
|
||||
width: 100%;
|
||||
max-width: 160px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: #dde8ef;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.source-composition__segment {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.source-composition__segment--fixed {
|
||||
background: #1f77b4;
|
||||
}
|
||||
|
||||
.source-composition__segment--subscription {
|
||||
background: #ff7f0e;
|
||||
}
|
||||
|
||||
.source-composition__segment--customer-prices {
|
||||
background: #c44536;
|
||||
}
|
||||
|
||||
.insights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
border: 1px solid #d8e5ee;
|
||||
}
|
||||
|
||||
.insight-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.top-month-list {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.top-month-button {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.overview-dock {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.kpi-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.kpi-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.insights-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.button:focus-visible),
|
||||
:deep(.input:focus-visible),
|
||||
:deep(.select select:focus-visible),
|
||||
:deep(.textarea:focus-visible) {
|
||||
outline: 2px solid rgba(15, 107, 134, 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 6,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skeleton-list">
|
||||
<div v-for="index in rows" :key="index" class="skeleton-row">
|
||||
<div class="skeleton-block w-30"></div>
|
||||
<div class="skeleton-block w-20"></div>
|
||||
<div class="skeleton-block w-20"></div>
|
||||
<div class="skeleton-block w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-block {
|
||||
height: 1rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(235, 238, 242, 1) 25%, rgba(217, 222, 230, 1) 37%, rgba(235, 238, 242, 1) 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: loading-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.w-30 {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.w-20 {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
@keyframes loading-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
} from 'chart.js';
|
||||
import {
|
||||
getYearMonthLabel,
|
||||
toNumber,
|
||||
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js';
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, LineElement, PointElement, CategoryScale, LinearScale);
|
||||
|
||||
const props = defineProps({
|
||||
months: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
labels: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
total: 'Total',
|
||||
booked: 'Booked',
|
||||
distribution: 'Distribution',
|
||||
customerPrices: 'Customer prices',
|
||||
}),
|
||||
},
|
||||
locale: {
|
||||
type: String,
|
||||
default: 'da-DK',
|
||||
},
|
||||
ariaLabel: {
|
||||
type: String,
|
||||
default: 'Distribution trend chart',
|
||||
},
|
||||
});
|
||||
|
||||
const sortedMonths = computed(() => {
|
||||
return [...(Array.isArray(props.months) ? props.months : [])]
|
||||
.sort((a, b) => {
|
||||
if (a.year !== b.year) {
|
||||
return a.year - b.year;
|
||||
}
|
||||
return a.month - b.month;
|
||||
});
|
||||
});
|
||||
|
||||
const chartData = computed(() => {
|
||||
const labels = sortedMonths.value.map((month) => getYearMonthLabel(month.year, month.month, props.locale));
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: props.labels.total,
|
||||
borderColor: '#1f77b4',
|
||||
backgroundColor: 'rgba(31,119,180,0.18)',
|
||||
data: sortedMonths.value.map((month) => toNumber(month.totalAmount)),
|
||||
tension: 0.25,
|
||||
},
|
||||
{
|
||||
label: props.labels.booked,
|
||||
borderColor: '#2ca02c',
|
||||
backgroundColor: 'rgba(44,160,44,0.18)',
|
||||
data: sortedMonths.value.map((month) => toNumber(month.bookedAmount)),
|
||||
tension: 0.25,
|
||||
},
|
||||
{
|
||||
label: props.labels.distribution,
|
||||
borderColor: '#ff7f0e',
|
||||
backgroundColor: 'rgba(255,127,14,0.18)',
|
||||
data: sortedMonths.value.map((month) => toNumber(month.distributionAmount)),
|
||||
tension: 0.25,
|
||||
},
|
||||
{
|
||||
label: props.labels.customerPrices,
|
||||
borderColor: '#c44536',
|
||||
backgroundColor: 'rgba(196,69,54,0.18)',
|
||||
data: sortedMonths.value.map((month) => toNumber(month.customerPriceAmount)),
|
||||
tension: 0.25,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chart-wrapper" v-if="sortedMonths.length" role="img" :aria-label="ariaLabel">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
<div class="chart-empty has-text-grey" v-else>-</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-wrapper {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
+622
@@ -0,0 +1,622 @@
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import {
|
||||
getMonthDateRange,
|
||||
buildMonthSummary,
|
||||
getMonthRange,
|
||||
chunkArray,
|
||||
normalizeCompareResult,
|
||||
aggregateCompareRows,
|
||||
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js';
|
||||
|
||||
const DISTRIBUTION_ENDPOINTS = {
|
||||
v2All: '/superuser/invoicing/period/distribution/v2/all',
|
||||
v2FixedPricing: '/superuser/invoicing/period/distribution/v2/fixed-pricing',
|
||||
v2WashSubscriptions: '/superuser/invoicing/period/distribution/v2/wash-subscriptions',
|
||||
v2CustomerPrices: '/superuser/invoicing/period/distribution/v2/customer-prices',
|
||||
legacyFixedPricing: '/superuser/invoicing/period/distribution/fixed-pricing',
|
||||
legacyWashSubscriptions: '/superuser/invoicing/period/distribution/wash-subscriptions',
|
||||
};
|
||||
|
||||
const COMPARE_ENDPOINTS = {
|
||||
v2Bulk: '/collected-invoices/economic/v2/compare/bulk',
|
||||
v2Single: '/collected-invoices/economic/v2/compare',
|
||||
legacySingle: '/collected-invoices/economic/compare',
|
||||
};
|
||||
|
||||
const safeArray = (value) => (Array.isArray(value) ? value : []);
|
||||
const uniqueStrings = (items = []) => Array.from(new Set(
|
||||
safeArray(items)
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter((item) => item.length > 0)
|
||||
));
|
||||
|
||||
const getResponseData = (response) => {
|
||||
return response?.data?.data ?? response?.data ?? response;
|
||||
};
|
||||
|
||||
const getPagination = (response) => {
|
||||
return response?.data?.meta?.pagination || response?.meta?.pagination || null;
|
||||
};
|
||||
|
||||
const toErrorMessage = (error) => {
|
||||
return SessionUser.functions.parseErrorMessage(error) || String(error?.message || error || 'Unknown error');
|
||||
};
|
||||
|
||||
const joinReasonMessages = (...reasons) => uniqueStrings(reasons).join(' | ');
|
||||
const waitForNextUiTick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const normalizeCategoryResponse = (category = {}, legacyCollectiveKey = null) => {
|
||||
const customers = safeArray(category?.customers || category?.data);
|
||||
const collectiveResults = (
|
||||
category?.collective_results
|
||||
|| category?.includes?.[legacyCollectiveKey]
|
||||
|| category?.includes?.collective_results
|
||||
|| {}
|
||||
);
|
||||
const warnings = safeArray(category?.warnings);
|
||||
|
||||
return {
|
||||
customers,
|
||||
collective_results: collectiveResults,
|
||||
warnings,
|
||||
// Keep legacy-compatible aliases for existing calculations and UI call sites.
|
||||
data: customers,
|
||||
includes: {
|
||||
...(category?.includes || {}),
|
||||
collective_results: collectiveResults,
|
||||
...(legacyCollectiveKey ? { [legacyCollectiveKey]: collectiveResults } : {}),
|
||||
},
|
||||
meta: category?.meta || {},
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeV2DistributionPayload = (payload = {}) => {
|
||||
const rawFixed = payload?.fixed_pricing || payload?.fixedPricing;
|
||||
const rawSubscriptions = payload?.wash_subscriptions || payload?.washSubscriptions;
|
||||
const rawCustomerPrices = payload?.customer_prices || payload?.customerPrices;
|
||||
|
||||
if (!rawFixed || !rawSubscriptions || !rawCustomerPrices) {
|
||||
throw new Error('V2 distribution payload is missing one or more required categories');
|
||||
}
|
||||
|
||||
return {
|
||||
fixedDistribution: normalizeCategoryResponse(rawFixed, 'collective_fixed_pricing_results'),
|
||||
subscriptionDistribution: normalizeCategoryResponse(rawSubscriptions, 'collective_subscription_results'),
|
||||
customerPriceDistribution: normalizeCategoryResponse(rawCustomerPrices, 'collective_customer_price_results'),
|
||||
};
|
||||
};
|
||||
|
||||
const fetchDistributionCategoryForMonth = async ({
|
||||
year,
|
||||
month,
|
||||
endpoint,
|
||||
legacyCollectiveKey,
|
||||
}) => {
|
||||
const { dateFrom, dateTo } = getMonthDateRange(year, month);
|
||||
const response = await SessionUser.request(endpoint, 'GET', {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
});
|
||||
return normalizeCategoryResponse(getResponseData(response), legacyCollectiveKey);
|
||||
};
|
||||
|
||||
export const fetchV2FixedPricingDistributionForMonth = async (year, month) => {
|
||||
return fetchDistributionCategoryForMonth({
|
||||
year,
|
||||
month,
|
||||
endpoint: DISTRIBUTION_ENDPOINTS.v2FixedPricing,
|
||||
legacyCollectiveKey: 'collective_fixed_pricing_results',
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchV2WashSubscriptionsDistributionForMonth = async (year, month) => {
|
||||
return fetchDistributionCategoryForMonth({
|
||||
year,
|
||||
month,
|
||||
endpoint: DISTRIBUTION_ENDPOINTS.v2WashSubscriptions,
|
||||
legacyCollectiveKey: 'collective_subscription_results',
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchV2CustomerPricesDistributionForMonth = async (year, month) => {
|
||||
return fetchDistributionCategoryForMonth({
|
||||
year,
|
||||
month,
|
||||
endpoint: DISTRIBUTION_ENDPOINTS.v2CustomerPrices,
|
||||
legacyCollectiveKey: 'collective_customer_price_results',
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchV2DistributionAllForMonth = async (year, month) => {
|
||||
const { dateFrom, dateTo } = getMonthDateRange(year, month);
|
||||
const response = await SessionUser.request(DISTRIBUTION_ENDPOINTS.v2All, 'GET', {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
});
|
||||
|
||||
return normalizeV2DistributionPayload(getResponseData(response));
|
||||
};
|
||||
|
||||
export const fetchV2DistributionCategoriesForMonth = async (year, month) => {
|
||||
const [fixedDistribution, subscriptionDistribution, customerPriceDistribution] = await Promise.all([
|
||||
fetchV2FixedPricingDistributionForMonth(year, month),
|
||||
fetchV2WashSubscriptionsDistributionForMonth(year, month),
|
||||
fetchV2CustomerPricesDistributionForMonth(year, month),
|
||||
]);
|
||||
|
||||
return {
|
||||
fixedDistribution,
|
||||
subscriptionDistribution,
|
||||
customerPriceDistribution,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchLegacyFixedPricingDistributionForMonth = async (year, month) => {
|
||||
return fetchDistributionCategoryForMonth({
|
||||
year,
|
||||
month,
|
||||
endpoint: DISTRIBUTION_ENDPOINTS.legacyFixedPricing,
|
||||
legacyCollectiveKey: 'collective_fixed_pricing_results',
|
||||
});
|
||||
};
|
||||
|
||||
const fetchLegacyWashSubscriptionsDistributionForMonth = async (year, month) => {
|
||||
return fetchDistributionCategoryForMonth({
|
||||
year,
|
||||
month,
|
||||
endpoint: DISTRIBUTION_ENDPOINTS.legacyWashSubscriptions,
|
||||
legacyCollectiveKey: 'collective_subscription_results',
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchLegacyDistributionForMonth = async (year, month) => {
|
||||
const [fixedDistribution, subscriptionDistribution] = await Promise.all([
|
||||
fetchLegacyFixedPricingDistributionForMonth(year, month),
|
||||
fetchLegacyWashSubscriptionsDistributionForMonth(year, month),
|
||||
]);
|
||||
|
||||
return {
|
||||
fixedDistribution,
|
||||
subscriptionDistribution,
|
||||
customerPriceDistribution: normalizeCategoryResponse({}, 'collective_customer_price_results'),
|
||||
};
|
||||
};
|
||||
|
||||
const fetchDistributionForMonth = async (year, month) => {
|
||||
let v2AllError = null;
|
||||
|
||||
try {
|
||||
const data = await fetchV2DistributionAllForMonth(year, month);
|
||||
return {
|
||||
...data,
|
||||
fallback: {
|
||||
usedLegacyDistribution: false,
|
||||
usedSplitV2Distribution: false,
|
||||
distributionFallbackReason: null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
v2AllError = error;
|
||||
}
|
||||
|
||||
try {
|
||||
const splitData = await fetchV2DistributionCategoriesForMonth(year, month);
|
||||
return {
|
||||
...splitData,
|
||||
fallback: {
|
||||
usedLegacyDistribution: false,
|
||||
usedSplitV2Distribution: true,
|
||||
distributionFallbackReason: toErrorMessage(v2AllError),
|
||||
},
|
||||
};
|
||||
} catch (splitError) {
|
||||
console.warn('Falling back to legacy distribution endpoints', splitError);
|
||||
const legacyData = await fetchLegacyDistributionForMonth(year, month);
|
||||
return {
|
||||
...legacyData,
|
||||
fallback: {
|
||||
usedLegacyDistribution: true,
|
||||
usedSplitV2Distribution: false,
|
||||
distributionFallbackReason: joinReasonMessages(
|
||||
toErrorMessage(v2AllError),
|
||||
toErrorMessage(splitError)
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchFirstOrderDate = async () => {
|
||||
try {
|
||||
const response = await SessionUser.request('/orders', 'GET', {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
order: 'created_at:asc',
|
||||
});
|
||||
const firstOrder = safeArray(getResponseData(response))[0];
|
||||
const firstCreatedAt = firstOrder?.created_at || firstOrder?.date || null;
|
||||
if (!firstCreatedAt) {
|
||||
return new Date();
|
||||
}
|
||||
const parsed = new Date(firstCreatedAt);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return new Date();
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn('Unable to fetch first order date, falling back to current month', error);
|
||||
return new Date();
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchPeriodForMonth = async (year, month) => {
|
||||
const { dateFrom, dateTo } = getMonthDateRange(year, month);
|
||||
const response = await SessionUser.request('/superuser/invoicing/period', 'GET', {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
});
|
||||
return getResponseData(response);
|
||||
};
|
||||
|
||||
export const fetchMonthData = async (year, month) => {
|
||||
const [periodData, distributionData] = await Promise.all([
|
||||
fetchPeriodForMonth(year, month),
|
||||
fetchDistributionForMonth(year, month),
|
||||
]);
|
||||
|
||||
const summary = buildMonthSummary({
|
||||
year,
|
||||
month,
|
||||
periodData,
|
||||
fixedDistribution: distributionData.fixedDistribution,
|
||||
subscriptionDistribution: distributionData.subscriptionDistribution,
|
||||
customerPriceDistribution: distributionData.customerPriceDistribution,
|
||||
});
|
||||
|
||||
return {
|
||||
summary,
|
||||
periodData,
|
||||
fixedDistribution: distributionData.fixedDistribution,
|
||||
subscriptionDistribution: distributionData.subscriptionDistribution,
|
||||
customerPriceDistribution: distributionData.customerPriceDistribution,
|
||||
fallback: distributionData.fallback,
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchDistributionOverviewMonths = async ({
|
||||
fromDate,
|
||||
toDate = new Date(),
|
||||
onProgress = null,
|
||||
}) => {
|
||||
const months = getMonthRange(fromDate, toDate, true);
|
||||
const results = [];
|
||||
|
||||
for (let index = 0; index < months.length; index += 1) {
|
||||
const month = months[index];
|
||||
const monthData = await fetchMonthData(month.year, month.month);
|
||||
results.push({
|
||||
...monthData.summary,
|
||||
fallback: monthData.fallback,
|
||||
});
|
||||
|
||||
if (typeof onProgress === 'function') {
|
||||
onProgress({
|
||||
total: months.length,
|
||||
loaded: index + 1,
|
||||
key: month.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const fetchDepartmentPage = async (page, limit) => {
|
||||
return SessionUser.request('/departments', 'GET', {
|
||||
page,
|
||||
limit,
|
||||
order: 'id:asc',
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchDepartments = async () => {
|
||||
const limit = 100;
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
const departments = [];
|
||||
|
||||
while (hasMore) {
|
||||
const response = await fetchDepartmentPage(page, limit);
|
||||
const pageData = safeArray(getResponseData(response));
|
||||
departments.push(...pageData);
|
||||
|
||||
const pagination = getPagination(response);
|
||||
if (!pagination) {
|
||||
hasMore = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(Number(pagination.total || 0) / Math.max(1, Number(pagination.per_page || limit)));
|
||||
hasMore = page < totalPages;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return departments;
|
||||
};
|
||||
|
||||
export const fetchCollectedInvoicesForMonth = async (year, month) => {
|
||||
const { dateFrom, dateTo } = getMonthDateRange(year, month);
|
||||
const limit = 100;
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
const invoices = [];
|
||||
|
||||
while (hasMore) {
|
||||
const response = await SessionUser.request('/collected-invoices', 'GET', {
|
||||
page,
|
||||
limit,
|
||||
filters: `closed_at-date_from:${dateFrom},closed_at-date_to:${dateTo}`,
|
||||
order: 'closed_at:asc',
|
||||
});
|
||||
|
||||
invoices.push(...safeArray(getResponseData(response)));
|
||||
|
||||
const pagination = getPagination(response);
|
||||
if (!pagination) {
|
||||
hasMore = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(Number(pagination.total || 0) / Math.max(1, Number(pagination.per_page || limit)));
|
||||
hasMore = page < totalPages;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return invoices;
|
||||
};
|
||||
|
||||
const compareCollectedInvoicesLegacy = async ({
|
||||
invoices,
|
||||
mode,
|
||||
onProgress,
|
||||
onRows,
|
||||
batchSize,
|
||||
}) => {
|
||||
const rows = [];
|
||||
let processed = 0;
|
||||
const chunks = chunkArray(invoices, batchSize);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const chunkResults = await Promise.all(chunk.map(async (invoice) => {
|
||||
try {
|
||||
const response = await SessionUser.request(COMPARE_ENDPOINTS.legacySingle, 'GET', {
|
||||
collected_invoice_id: Number(invoice?.id),
|
||||
});
|
||||
return normalizeCompareResult(invoice, getResponseData(response), mode);
|
||||
} catch (error) {
|
||||
const fallbackPayload = {
|
||||
collected_invoice_id: invoice?.id,
|
||||
warnings: [toErrorMessage(error)],
|
||||
internal_total: invoice?.total_net_amount || 0,
|
||||
booked_total: null,
|
||||
draft_total: null,
|
||||
difference: null,
|
||||
order_ids: [],
|
||||
};
|
||||
const normalized = normalizeCompareResult(invoice, fallbackPayload, mode);
|
||||
normalized.status = 'mismatch';
|
||||
return normalized;
|
||||
}
|
||||
}));
|
||||
|
||||
for (const row of chunkResults) {
|
||||
rows.push(row);
|
||||
processed += 1;
|
||||
|
||||
if (typeof onRows === 'function') {
|
||||
onRows([row]);
|
||||
await waitForNextUiTick();
|
||||
}
|
||||
|
||||
if (typeof onProgress === 'function') {
|
||||
onProgress({
|
||||
total: invoices.length,
|
||||
processed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const createCompareFallbackRow = (invoice, mode, warningMessage) => {
|
||||
const fallbackPayload = {
|
||||
collected_invoice_id: Number(invoice?.id) || null,
|
||||
warnings: uniqueStrings([warningMessage || 'Unable to compare invoice']),
|
||||
internal_total: invoice?.total_net_amount || 0,
|
||||
booked_total: null,
|
||||
draft_total: null,
|
||||
difference: null,
|
||||
order_ids: [],
|
||||
};
|
||||
const normalized = normalizeCompareResult(invoice, fallbackPayload, mode);
|
||||
normalized.status = 'mismatch';
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const compareInvoiceWithV2Single = async ({
|
||||
invoice,
|
||||
mode,
|
||||
fallbackWarning,
|
||||
}) => {
|
||||
try {
|
||||
const response = await SessionUser.request(COMPARE_ENDPOINTS.v2Single, 'GET', {
|
||||
collected_invoice_id: Number(invoice?.id),
|
||||
});
|
||||
return normalizeCompareResult(invoice, getResponseData(response), mode);
|
||||
} catch (error) {
|
||||
return createCompareFallbackRow(
|
||||
invoice,
|
||||
mode,
|
||||
joinReasonMessages(fallbackWarning, toErrorMessage(error))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const compareCollectedInvoicesV2 = async ({
|
||||
invoices,
|
||||
mode,
|
||||
onProgress,
|
||||
onRows,
|
||||
batchSize,
|
||||
}) => {
|
||||
const rows = [];
|
||||
let processed = 0;
|
||||
let usedSingleCompareFallback = false;
|
||||
let singleCompareFallbackReason = null;
|
||||
const safeBatchSize = Math.min(200, Math.max(1, Number(batchSize) || 1));
|
||||
const chunks = chunkArray(invoices, safeBatchSize);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const ids = chunk
|
||||
.map((invoice) => Number(invoice?.id))
|
||||
.filter((id) => Number.isInteger(id) && id > 0);
|
||||
|
||||
const unresolved = [];
|
||||
const chunkRows = [];
|
||||
|
||||
const response = await SessionUser.request(COMPARE_ENDPOINTS.v2Bulk, 'POST', {
|
||||
collected_invoice_ids: ids,
|
||||
});
|
||||
const payload = getResponseData(response) || {};
|
||||
const resultRows = safeArray(payload?.results);
|
||||
const errors = safeArray(payload?.errors);
|
||||
const resultMap = new Map(resultRows.map((result) => [Number(result?.collected_invoice_id), result]));
|
||||
const errorMap = new Map(errors.map((entry) => [Number(entry?.collected_invoice_id), entry]));
|
||||
|
||||
for (const invoice of chunk) {
|
||||
const invoiceId = Number(invoice?.id);
|
||||
const comparePayload = resultMap.get(invoiceId);
|
||||
if (comparePayload) {
|
||||
chunkRows.push(normalizeCompareResult(invoice, comparePayload, mode));
|
||||
continue;
|
||||
}
|
||||
|
||||
unresolved.push({
|
||||
invoice,
|
||||
warning: errorMap.get(invoiceId)?.error || 'Missing comparison result from v2 bulk response',
|
||||
});
|
||||
}
|
||||
|
||||
if (unresolved.length) {
|
||||
usedSingleCompareFallback = true;
|
||||
singleCompareFallbackReason = singleCompareFallbackReason || unresolved[0]?.warning || null;
|
||||
|
||||
const recoveredRows = await Promise.all(unresolved.map(async ({ invoice, warning }) => {
|
||||
return compareInvoiceWithV2Single({
|
||||
invoice,
|
||||
mode,
|
||||
fallbackWarning: warning,
|
||||
});
|
||||
}));
|
||||
|
||||
chunkRows.push(...recoveredRows);
|
||||
}
|
||||
|
||||
for (const row of chunkRows) {
|
||||
rows.push(row);
|
||||
processed += 1;
|
||||
|
||||
if (typeof onRows === 'function') {
|
||||
onRows([row]);
|
||||
await waitForNextUiTick();
|
||||
}
|
||||
|
||||
if (typeof onProgress === 'function') {
|
||||
onProgress({
|
||||
total: invoices.length,
|
||||
processed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
usedSingleCompareFallback,
|
||||
singleCompareFallbackReason,
|
||||
};
|
||||
};
|
||||
|
||||
export const compareCollectedInvoicesForMonth = async ({
|
||||
year,
|
||||
month,
|
||||
mode = 'invoice_total',
|
||||
onProgress = null,
|
||||
onRows = null,
|
||||
batchSize = 100,
|
||||
legacyBatchSize = 5,
|
||||
}) => {
|
||||
const invoices = await fetchCollectedInvoicesForMonth(year, month);
|
||||
if (!invoices.length) {
|
||||
return {
|
||||
rows: [],
|
||||
summary: aggregateCompareRows([]),
|
||||
invoiceCount: 0,
|
||||
fallback: {
|
||||
usedLegacyCompare: false,
|
||||
usedSingleCompareFallback: false,
|
||||
compareFallbackReason: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof onProgress === 'function') {
|
||||
onProgress({
|
||||
total: invoices.length,
|
||||
processed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await compareCollectedInvoicesV2({
|
||||
invoices,
|
||||
mode,
|
||||
onProgress,
|
||||
onRows,
|
||||
batchSize,
|
||||
});
|
||||
|
||||
return {
|
||||
rows: result.rows,
|
||||
summary: aggregateCompareRows(result.rows),
|
||||
invoiceCount: invoices.length,
|
||||
fallback: {
|
||||
usedLegacyCompare: false,
|
||||
usedSingleCompareFallback: result.usedSingleCompareFallback,
|
||||
compareFallbackReason: result.singleCompareFallbackReason,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Falling back to legacy compare endpoint', error);
|
||||
const rows = await compareCollectedInvoicesLegacy({
|
||||
invoices,
|
||||
mode,
|
||||
onProgress,
|
||||
onRows,
|
||||
batchSize: legacyBatchSize,
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
summary: aggregateCompareRows(rows),
|
||||
invoiceCount: invoices.length,
|
||||
fallback: {
|
||||
usedLegacyCompare: true,
|
||||
usedSingleCompareFallback: false,
|
||||
compareFallbackReason: toErrorMessage(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
+1033
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,381 @@
|
||||
<script setup>
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from "vue-router";
|
||||
import XLVaskUsageLog from "@/views/dashboards/superUserDashboard/vehicle/displays/XLVaskUsageLog.vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import RestrictedPageWrapper from '@/components/page/wrappers/RestrictedPageWrapper.vue';
|
||||
import SuperUserDashboardNavigation from '@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue';
|
||||
import PageTitle from '@/components/global/PageTitle.vue';
|
||||
import VehicleAnalyticsChart from '@/views/dashboards/superUserDashboard/vehicle/displays/VehicleAnalyticsChart.vue';
|
||||
import {
|
||||
aggregateDepartmentVisits,
|
||||
aggregateOrderFrequency,
|
||||
aggregateOrderItemFrequency,
|
||||
buildOverviewStats,
|
||||
buildUsageLogQuery,
|
||||
buildVehiclesQuery,
|
||||
clampPerPage,
|
||||
fromDatetimeLocalValue,
|
||||
normalizeListResponse,
|
||||
normalizeRegistrationNumber,
|
||||
normalizeRelatedOrders,
|
||||
sanitizePositiveInt,
|
||||
toDatetimeLocalValue,
|
||||
uniqueCustomerIdsFromUsageLogs,
|
||||
} from '@/views/dashboards/superUserDashboard/vehicle/imports/vehicleViewUtils.js';
|
||||
|
||||
const router = useRouter();
|
||||
// Get the registration from the route
|
||||
const reg = ref(router.currentRoute.value.params.reg);
|
||||
const dateFrom = ref(router.currentRoute.value.query.dateFrom);
|
||||
const route = useRoute();
|
||||
const registrationNumber = computed(() => normalizeRegistrationNumber(route.params.registrationnumber || route.params.reg));
|
||||
const activeTab = ref('overview');
|
||||
|
||||
const parsedDateFrom = ref(null);
|
||||
if (dateFrom.value !== undefined && dateFrom.value !== null) {
|
||||
parsedDateFrom.value = SessionUser.superUser.modules.xlvask.functions.convertISODateTimeToDate(dateFrom.value);
|
||||
} else {
|
||||
parsedDateFrom.value = null;
|
||||
}
|
||||
const loading = reactive({
|
||||
all: false,
|
||||
washes: false,
|
||||
details: false,
|
||||
customers: false,
|
||||
search: false,
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
washes: '',
|
||||
details: '',
|
||||
customers: '',
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
|
||||
const usageLogs = ref([]);
|
||||
const relatedOrders = ref({});
|
||||
const vehicleStatus = ref(null);
|
||||
const vehicles = ref([]);
|
||||
const xlvaskVehicles = ref([]);
|
||||
const customers = ref([]);
|
||||
const subscriptionUsers = ref([]);
|
||||
const searchResults = ref([]);
|
||||
|
||||
const filters = reactive({
|
||||
washes: {
|
||||
dateFrom: toDatetimeLocalValue(route.query.dateFrom || null),
|
||||
page: sanitizePositiveInt(route.query.page, 1, 1),
|
||||
perPage: clampPerPage(route.query.perPage || route.query.per_page, 30),
|
||||
customerId: '',
|
||||
vehicleId: '',
|
||||
},
|
||||
details: {
|
||||
id: '',
|
||||
customerId: '',
|
||||
page: 1,
|
||||
perPage: 30,
|
||||
},
|
||||
customers: {
|
||||
customerId: '',
|
||||
customerName: '',
|
||||
customerExternalId: '',
|
||||
vatNumber: '',
|
||||
},
|
||||
search: {
|
||||
term: '',
|
||||
},
|
||||
});
|
||||
|
||||
const parseError = (error) => {
|
||||
const parsed = SessionUser.functions.parseErrorMessage(error);
|
||||
if (typeof parsed === 'string') return parsed;
|
||||
if (parsed && typeof parsed === 'object') return JSON.stringify(parsed);
|
||||
return 'Unknown error.';
|
||||
};
|
||||
|
||||
const relatedCustomerIds = computed(() => uniqueCustomerIdsFromUsageLogs(usageLogs.value));
|
||||
const pagedUsageLogs = computed(() => {
|
||||
const page = sanitizePositiveInt(filters.washes.page, 1, 1);
|
||||
const perPage = clampPerPage(filters.washes.perPage, 30);
|
||||
const start = (page - 1) * perPage;
|
||||
return usageLogs.value.slice(start, start + perPage);
|
||||
});
|
||||
const washPageCount = computed(() => {
|
||||
return Math.max(1, Math.ceil(usageLogs.value.length / clampPerPage(filters.washes.perPage, 30)));
|
||||
});
|
||||
const overviewStats = computed(() => buildOverviewStats(usageLogs.value, relatedOrders.value));
|
||||
const orderFrequencyPoints = computed(() => aggregateOrderFrequency(usageLogs.value, relatedOrders.value));
|
||||
const orderItemPoints = computed(() => aggregateOrderItemFrequency(usageLogs.value, 12));
|
||||
const departmentVisitPoints = computed(() => aggregateDepartmentVisits(usageLogs.value));
|
||||
const relatedSubscriptionUsers = computed(() => {
|
||||
const related = new Set(relatedCustomerIds.value.map((id) => String(id)));
|
||||
return subscriptionUsers.value.filter((user) => {
|
||||
const number = String(user?.customer_number ?? user?.customerNumber ?? user?.billing_customer_number ?? '');
|
||||
return related.size === 0 || related.has(number);
|
||||
});
|
||||
});
|
||||
|
||||
const loadStatus = async () => {
|
||||
errors.status = '';
|
||||
try {
|
||||
const response = await SessionUser.request('/vehicles/status', 'GET', {
|
||||
reg: registrationNumber.value,
|
||||
});
|
||||
vehicleStatus.value = response?.data?.data || response?.data || null;
|
||||
} catch (error) {
|
||||
errors.status = parseError(error);
|
||||
vehicleStatus.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadWashes = async () => {
|
||||
loading.washes = true;
|
||||
errors.washes = '';
|
||||
try {
|
||||
const response = await SessionUser.request('/modules/xlvask/usageLog', 'GET', buildUsageLogQuery({
|
||||
dateFrom: fromDatetimeLocalValue(filters.washes.dateFrom),
|
||||
regNr: registrationNumber.value,
|
||||
customerId: filters.washes.customerId || null,
|
||||
vehicleId: filters.washes.vehicleId || null,
|
||||
page: filters.washes.page,
|
||||
perPage: filters.washes.perPage,
|
||||
}));
|
||||
usageLogs.value = normalizeListResponse(response);
|
||||
|
||||
const washIds = usageLogs.value.map((entry) => entry?.WashId).filter((id) => !!id);
|
||||
if (washIds.length) {
|
||||
const relatedResponse = await SessionUser.superUser.modules.xlvask.functions.getRelatedOrders(washIds);
|
||||
relatedOrders.value = normalizeRelatedOrders(relatedResponse);
|
||||
} else {
|
||||
relatedOrders.value = {};
|
||||
}
|
||||
} catch (error) {
|
||||
errors.washes = parseError(error);
|
||||
usageLogs.value = [];
|
||||
relatedOrders.value = {};
|
||||
} finally {
|
||||
loading.washes = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetails = async () => {
|
||||
loading.details = true;
|
||||
errors.details = '';
|
||||
try {
|
||||
const vehicleResponse = await SessionUser.request('/vehicles', 'GET', buildVehiclesQuery({
|
||||
id: filters.details.id || null,
|
||||
reg: registrationNumber.value,
|
||||
customerId: filters.details.customerId || null,
|
||||
page: filters.details.page,
|
||||
perPage: filters.details.perPage,
|
||||
}));
|
||||
vehicles.value = normalizeListResponse(vehicleResponse);
|
||||
|
||||
const xlvaskResponse = await SessionUser.request('/modules/xlvask/vehicles', 'GET', {
|
||||
customerId: filters.details.customerId || null,
|
||||
registrationNumber: registrationNumber.value,
|
||||
});
|
||||
xlvaskVehicles.value = normalizeListResponse(xlvaskResponse);
|
||||
} catch (error) {
|
||||
errors.details = parseError(error);
|
||||
vehicles.value = [];
|
||||
xlvaskVehicles.value = [];
|
||||
} finally {
|
||||
loading.details = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCustomers = async () => {
|
||||
loading.customers = true;
|
||||
errors.customers = '';
|
||||
try {
|
||||
const requests = [];
|
||||
if (relatedCustomerIds.value.length) {
|
||||
relatedCustomerIds.value.forEach((customerId) => {
|
||||
requests.push(SessionUser.request('/modules/xlvask/customers', 'GET', { customerId }));
|
||||
});
|
||||
} else {
|
||||
requests.push(
|
||||
SessionUser.request('/modules/xlvask/customers', 'GET', {
|
||||
customerId: filters.customers.customerId || null,
|
||||
customerName: filters.customers.customerName || null,
|
||||
customerExternalId: filters.customers.customerExternalId || null,
|
||||
vatNumber: filters.customers.vatNumber || null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(requests);
|
||||
customers.value = settled
|
||||
.filter((entry) => entry.status === 'fulfilled')
|
||||
.flatMap((entry) => normalizeListResponse(entry.value));
|
||||
|
||||
const subscriptionsResponse = await SessionUser.request('/superuser/users-with-vehicle-subscriptions', 'GET', {});
|
||||
subscriptionUsers.value = normalizeListResponse(subscriptionsResponse);
|
||||
} catch (error) {
|
||||
errors.customers = parseError(error);
|
||||
customers.value = [];
|
||||
subscriptionUsers.value = [];
|
||||
} finally {
|
||||
loading.customers = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runSearch = async () => {
|
||||
loading.search = true;
|
||||
errors.search = '';
|
||||
try {
|
||||
const term = String(filters.search.term || registrationNumber.value || '').trim();
|
||||
const response = await SessionUser.request('/vehicles/search', 'GET', { search: term });
|
||||
searchResults.value = normalizeListResponse(response);
|
||||
} catch (error) {
|
||||
errors.search = parseError(error);
|
||||
searchResults.value = [];
|
||||
} finally {
|
||||
loading.search = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
loading.all = true;
|
||||
await Promise.all([loadStatus(), loadWashes(), loadDetails()]);
|
||||
await loadCustomers();
|
||||
loading.all = false;
|
||||
};
|
||||
|
||||
const openOrder = async (orderId) => {
|
||||
const departmentId = await SessionUser.objects.orders.functions.get_department_id(orderId);
|
||||
if (departmentId) window.open(`/admin/${departmentId}/modules/pos/orders/${orderId}`, '_blank');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
filters.search.term = registrationNumber.value;
|
||||
await refreshAll();
|
||||
});
|
||||
|
||||
watch(registrationNumber, async (next, prev) => {
|
||||
if (!next || next === prev) return;
|
||||
filters.search.term = next;
|
||||
await refreshAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<XLVaskUsageLog v-bind:reg="reg" v-bind:dateFrom="parsedDateFrom" />
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<SuperUserDashboardNavigation />
|
||||
<section class="section">
|
||||
<PageTitle :title="`Vehicle ${registrationNumber || '-'}`" subtitle="Advanced superuser vehicle view" />
|
||||
<div class="box mb-4">
|
||||
<b-button type="is-info" icon-left="sync" icon-pack="fas" :loading="loading.all" @click="refreshAll">Refresh all</b-button>
|
||||
</div>
|
||||
|
||||
<b-tabs v-model="activeTab" expanded type="is-boxed">
|
||||
<b-tab-item value="overview" label="Overview" icon="tachometer-alt" icon-pack="fas">
|
||||
<div class="columns">
|
||||
<div class="column"><div class="box">Washes: <strong>{{ overviewStats.totalWashes }}</strong></div></div>
|
||||
<div class="column"><div class="box">Customers: <strong>{{ overviewStats.uniqueCustomers }}</strong></div></div>
|
||||
<div class="column"><div class="box">Orders: <strong>{{ overviewStats.linkedOrders }}</strong></div></div>
|
||||
<div class="column"><div class="box">Departments: <strong>{{ overviewStats.uniqueDepartments }}</strong></div></div>
|
||||
</div>
|
||||
<article class="message is-danger" v-if="errors.status"><div class="message-body">{{ errors.status }}</div></article>
|
||||
<div class="box"><pre>{{ JSON.stringify(vehicleStatus || {}, null, 2) }}</pre></div>
|
||||
</b-tab-item>
|
||||
|
||||
<b-tab-item value="washes" label="Washes" icon="water" icon-pack="fas">
|
||||
<article class="message is-danger" v-if="errors.washes"><div class="message-body">{{ errors.washes }}</div></article>
|
||||
<div class="box mb-3">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-3"><b-field label="Date from"><b-input v-model="filters.washes.dateFrom" type="datetime-local" /></b-field></div>
|
||||
<div class="column is-2"><b-field label="Customer ID"><b-input v-model="filters.washes.customerId" /></b-field></div>
|
||||
<div class="column is-3"><b-field label="Vehicle ID"><b-input v-model="filters.washes.vehicleId" /></b-field></div>
|
||||
<div class="column is-2"><b-field label="Page"><b-input v-model.number="filters.washes.page" type="number" min="1" /></b-field></div>
|
||||
<div class="column is-2"><b-field label="Per page"><b-select v-model="filters.washes.perPage" expanded><option :value="10">10</option><option :value="30">30</option><option :value="50">50</option><option :value="100">100</option></b-select></b-field></div>
|
||||
</div>
|
||||
<b-button type="is-info" icon-left="filter" icon-pack="fas" :loading="loading.washes" @click="loadWashes">Apply filters</b-button>
|
||||
</div>
|
||||
<b-table :data="pagedUsageLogs" :loading="loading.washes" striped hoverable>
|
||||
<b-table-column field="Customer" label="Customer" v-slot="props">{{ props.row.Customer || props.row.CustomerId }}</b-table-column>
|
||||
<b-table-column field="RegistrationNumber" label="Registration" v-slot="props">{{ props.row.RegistrationNumber }}</b-table-column>
|
||||
<b-table-column field="StartTime" label="Start" v-slot="props">{{ props.row.StartTime }}</b-table-column>
|
||||
<b-table-column field="Location" label="Department" v-slot="props">{{ props.row.Location }}</b-table-column>
|
||||
<b-table-column field="orders" label="Related orders" v-slot="props">
|
||||
<b-tag v-for="orderId in (relatedOrders[props.row.WashId] || [])" :key="`${props.row.WashId}-${orderId}`" type="is-success" class="mr-1 mb-1" @click="openOrder(orderId)">#{{ orderId }}</b-tag>
|
||||
</b-table-column>
|
||||
</b-table>
|
||||
<p class="is-size-7 has-text-grey mt-2">Page {{ filters.washes.page }} / {{ washPageCount }}</p>
|
||||
</b-tab-item>
|
||||
|
||||
<b-tab-item value="customers" label="Customers" icon="users" icon-pack="fas">
|
||||
<article class="message is-danger" v-if="errors.customers"><div class="message-body">{{ errors.customers }}</div></article>
|
||||
<div class="box mb-3">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-3"><b-field label="Customer ID"><b-input v-model="filters.customers.customerId" /></b-field></div>
|
||||
<div class="column is-3"><b-field label="Name"><b-input v-model="filters.customers.customerName" /></b-field></div>
|
||||
<div class="column is-3"><b-field label="External ID"><b-input v-model="filters.customers.customerExternalId" /></b-field></div>
|
||||
<div class="column is-3"><b-field label="VAT"><b-input v-model="filters.customers.vatNumber" /></b-field></div>
|
||||
</div>
|
||||
<b-button type="is-info" icon-left="filter" icon-pack="fas" :loading="loading.customers" @click="loadCustomers">Apply filters</b-button>
|
||||
</div>
|
||||
<h4 class="title is-6">XLVask customers</h4>
|
||||
<b-table :data="customers" :loading="loading.customers" striped hoverable>
|
||||
<b-table-column field="customerId" label="Customer ID" v-slot="props">{{ props.row.customerId }}</b-table-column>
|
||||
<b-table-column field="name" label="Name" v-slot="props">{{ props.row.name }}</b-table-column>
|
||||
<b-table-column field="externId" label="External ID" v-slot="props">{{ props.row.externId }}</b-table-column>
|
||||
<b-table-column field="vatnumber" label="VAT" v-slot="props">{{ props.row.vatnumber }}</b-table-column>
|
||||
</b-table>
|
||||
<h4 class="title is-6 mt-4">Users with vehicle subscriptions</h4>
|
||||
<b-table :data="relatedSubscriptionUsers" :loading="loading.customers" striped hoverable>
|
||||
<b-table-column field="id" label="User ID" v-slot="props">{{ props.row.id }}</b-table-column>
|
||||
<b-table-column field="customer_number" label="Customer #" v-slot="props">{{ props.row.customer_number || props.row.customerNumber }}</b-table-column>
|
||||
<b-table-column field="customer_name" label="Name" v-slot="props">{{ props.row.customer_name || props.row.display_name }}</b-table-column>
|
||||
<b-table-column field="email" label="Email" v-slot="props">{{ props.row.email }}</b-table-column>
|
||||
</b-table>
|
||||
</b-tab-item>
|
||||
|
||||
<b-tab-item value="details" label="Vehicle details" icon="car-side" icon-pack="fas">
|
||||
<article class="message is-danger" v-if="errors.details"><div class="message-body">{{ errors.details }}</div></article>
|
||||
<div class="box mb-3">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-2"><b-field label="Vehicle ID"><b-input v-model="filters.details.id" /></b-field></div>
|
||||
<div class="column is-2"><b-field label="Customer ID"><b-input v-model="filters.details.customerId" /></b-field></div>
|
||||
<div class="column is-2"><b-field label="Page"><b-input v-model.number="filters.details.page" type="number" min="1" /></b-field></div>
|
||||
<div class="column is-2"><b-field label="Per page"><b-select v-model="filters.details.perPage" expanded><option :value="10">10</option><option :value="30">30</option><option :value="50">50</option><option :value="100">100</option></b-select></b-field></div>
|
||||
</div>
|
||||
<b-button type="is-info" icon-left="sync" icon-pack="fas" :loading="loading.details" @click="loadDetails">Refresh details</b-button>
|
||||
</div>
|
||||
<h4 class="title is-6">`/vehicles`</h4>
|
||||
<b-table :data="vehicles" :loading="loading.details" striped hoverable>
|
||||
<b-table-column field="id" label="ID" v-slot="props">{{ props.row.id }}</b-table-column>
|
||||
<b-table-column field="reg" label="Registration" v-slot="props">{{ props.row.reg }}</b-table-column>
|
||||
<b-table-column field="customer_id" label="Customer ID" v-slot="props">{{ props.row.customer_id }}</b-table-column>
|
||||
<b-table-column field="type" label="Type" v-slot="props">{{ props.row.type }}</b-table-column>
|
||||
<b-table-column field="wash_subscription" label="Subscription" v-slot="props">{{ props.row.wash_subscription ? 'Yes' : 'No' }}</b-table-column>
|
||||
</b-table>
|
||||
<h4 class="title is-6 mt-4">`/modules/xlvask/vehicles`</h4>
|
||||
<b-table :data="xlvaskVehicles" :loading="loading.details" striped hoverable>
|
||||
<b-table-column field="vehicleId" label="Vehicle ID" v-slot="props">{{ props.row.vehicleId }}</b-table-column>
|
||||
<b-table-column field="registrationNumber" label="Registration" v-slot="props">{{ props.row.registrationNumber || props.row.RegistrationNumber }}</b-table-column>
|
||||
<b-table-column field="customerId" label="Customer ID" v-slot="props">{{ props.row.customerId }}</b-table-column>
|
||||
<b-table-column field="active" label="Active" v-slot="props">{{ props.row.active ? 'Yes' : 'No' }}</b-table-column>
|
||||
</b-table>
|
||||
<div class="box mt-4">
|
||||
<h4 class="title is-6">`/vehicles/search`</h4>
|
||||
<b-field label="Search term"><b-input v-model="filters.search.term" /></b-field>
|
||||
<b-button type="is-info" icon-left="search" icon-pack="fas" :loading="loading.search" @click="runSearch">Search</b-button>
|
||||
<article class="message is-danger mt-2" v-if="errors.search"><div class="message-body">{{ errors.search }}</div></article>
|
||||
<b-table class="mt-3" :data="searchResults" :loading="loading.search" striped hoverable>
|
||||
<b-table-column field="id" label="ID" v-slot="props">{{ props.row.id }}</b-table-column>
|
||||
<b-table-column field="reg" label="Registration" v-slot="props">{{ props.row.reg }}</b-table-column>
|
||||
<b-table-column field="customer_id" label="Customer ID" v-slot="props">{{ props.row.customer_id }}</b-table-column>
|
||||
</b-table>
|
||||
</div>
|
||||
</b-tab-item>
|
||||
|
||||
<b-tab-item value="diagrams" label="Diagrams" icon="chart-bar" icon-pack="fas">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<VehicleAnalyticsChart title="Order frequency" :points="orderFrequencyPoints" color="#1f77b4" mode="line" />
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<VehicleAnalyticsChart title="Order item frequency" :points="orderItemPoints" color="#2ca02c" />
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<VehicleAnalyticsChart title="Department visits" :points="departmentVisitPoints" color="#ff7f0e" />
|
||||
</div>
|
||||
</div>
|
||||
</b-tab-item>
|
||||
</b-tabs>
|
||||
</section>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Bar, Line } from 'vue-chartjs';
|
||||
import {
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
Legend,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, LineElement, PointElement);
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
points: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#3273dc',
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'bar',
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = computed(() => {
|
||||
const labels = props.points.map((point) => point.label);
|
||||
const values = props.points.map((point) => point.value);
|
||||
const color = props.color;
|
||||
const backgroundColor = `${color}33`;
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: props.title,
|
||||
data: values,
|
||||
borderColor: color,
|
||||
backgroundColor,
|
||||
borderWidth: 2,
|
||||
tension: 0.25,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const chartMode = computed(() => (props.mode === 'line' ? 'line' : 'bar'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vehicle-chart">
|
||||
<div class="vehicle-chart__title">{{ title }}</div>
|
||||
<div v-if="points.length" class="vehicle-chart__canvas">
|
||||
<Line v-if="chartMode === 'line'" :data="chartData" :options="chartOptions" />
|
||||
<Bar v-else :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
<div v-else class="vehicle-chart__empty">No chart data yet.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vehicle-chart {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.vehicle-chart__title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vehicle-chart__canvas {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.vehicle-chart__empty {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7a7a7a;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_PER_PAGE = 30;
|
||||
const MAX_PER_PAGE = 100;
|
||||
|
||||
export const VEHICLE_VIEW_TABS = ['overview', 'washes', 'customers', 'details', 'diagrams'];
|
||||
|
||||
export const sanitizePositiveInt = (value, fallback = 1, min = 1, max = Number.MAX_SAFE_INTEGER) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || Number.isNaN(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
if (parsed < min) {
|
||||
return min;
|
||||
}
|
||||
if (parsed > max) {
|
||||
return max;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const clampPerPage = (value, fallback = DEFAULT_PER_PAGE) => {
|
||||
return sanitizePositiveInt(value, fallback, 1, MAX_PER_PAGE);
|
||||
};
|
||||
|
||||
export const normalizeRegistrationNumber = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(String(value)).trim().toUpperCase();
|
||||
} catch (error) {
|
||||
return String(value).trim().toUpperCase();
|
||||
}
|
||||
};
|
||||
|
||||
export const toIsoDateTimeWithoutTimezone = (value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const year = String(date.getFullYear());
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
const milliseconds = String(date.getMilliseconds()).padStart(3, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
};
|
||||
|
||||
export const toDatetimeLocalValue = (value) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const year = String(date.getFullYear());
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export const fromDatetimeLocalValue = (value) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
export const buildUsageLogQuery = ({
|
||||
dateFrom = null,
|
||||
regNr = null,
|
||||
customerId = null,
|
||||
vehicleId = null,
|
||||
page = DEFAULT_PAGE,
|
||||
perPage = DEFAULT_PER_PAGE,
|
||||
} = {}) => {
|
||||
const safePage = sanitizePositiveInt(page, DEFAULT_PAGE, 1);
|
||||
const safePerPage = clampPerPage(perPage, DEFAULT_PER_PAGE);
|
||||
const query = {
|
||||
page: safePage,
|
||||
per_page: safePerPage,
|
||||
perPage: safePerPage,
|
||||
};
|
||||
|
||||
const isoDateFrom = toIsoDateTimeWithoutTimezone(dateFrom);
|
||||
if (isoDateFrom) {
|
||||
query.dateFrom = isoDateFrom;
|
||||
}
|
||||
if (regNr !== null && regNr !== undefined && String(regNr).trim().length) {
|
||||
query.regNr = String(regNr).trim();
|
||||
}
|
||||
if (customerId !== null && customerId !== undefined && String(customerId).trim().length) {
|
||||
query.customerId = String(customerId).trim();
|
||||
}
|
||||
if (vehicleId !== null && vehicleId !== undefined && String(vehicleId).trim().length) {
|
||||
query.vehicleId = String(vehicleId).trim();
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
export const buildVehiclesQuery = ({
|
||||
id = null,
|
||||
reg = null,
|
||||
customerId = null,
|
||||
page = DEFAULT_PAGE,
|
||||
perPage = DEFAULT_PER_PAGE,
|
||||
} = {}) => {
|
||||
const safePage = sanitizePositiveInt(page, DEFAULT_PAGE, 1);
|
||||
const safePerPage = clampPerPage(perPage, DEFAULT_PER_PAGE);
|
||||
const query = {
|
||||
page: safePage,
|
||||
per_page: safePerPage,
|
||||
perPage: safePerPage,
|
||||
};
|
||||
if (id !== null && id !== undefined && String(id).trim().length) {
|
||||
query.id = sanitizePositiveInt(id, null, 1);
|
||||
}
|
||||
if (reg !== null && reg !== undefined && String(reg).trim().length) {
|
||||
query.reg = String(reg).trim();
|
||||
}
|
||||
if (customerId !== null && customerId !== undefined && String(customerId).trim().length) {
|
||||
query.customer_id = sanitizePositiveInt(customerId, null, 1);
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
export const normalizeListResponse = (response) => {
|
||||
const data = response?.data?.data ?? response?.data ?? response;
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (Array.isArray(data?.results)) {
|
||||
return data.results;
|
||||
}
|
||||
if (data && typeof data === 'object') {
|
||||
return [data];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const normalizeRelatedOrders = (response) => {
|
||||
const data = response?.data?.data ?? response?.data ?? {};
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const extractDateKey = (value) => {
|
||||
if (!value) {
|
||||
return 'Unknown';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
export const aggregateOrderFrequency = (usageLogs = [], relatedOrders = null) => {
|
||||
const grouped = new Map();
|
||||
usageLogs.forEach((entry) => {
|
||||
const dateKey = extractDateKey(entry?.StartTime || entry?.FinishTime);
|
||||
const washId = entry?.WashId;
|
||||
let increment = 1;
|
||||
if (relatedOrders && washId && Array.isArray(relatedOrders[washId])) {
|
||||
increment = relatedOrders[washId].length;
|
||||
}
|
||||
if (increment <= 0) {
|
||||
return;
|
||||
}
|
||||
grouped.set(dateKey, (grouped.get(dateKey) || 0) + increment);
|
||||
});
|
||||
return [...grouped.entries()]
|
||||
.map(([label, value]) => ({ label, value }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
export const aggregateOrderItemFrequency = (usageLogs = [], topN = 12) => {
|
||||
const grouped = new Map();
|
||||
usageLogs.forEach((entry) => {
|
||||
const washItems = Array.isArray(entry?.WashItems) ? entry.WashItems : [];
|
||||
washItems.forEach((washItem) => {
|
||||
const label = washItem?.OriginalProductName
|
||||
|| washItem?.ExternalProductName
|
||||
|| 'Unknown';
|
||||
const count = Number.isFinite(Number(washItem?.Count))
|
||||
? Number(washItem.Count)
|
||||
: 1;
|
||||
grouped.set(label, (grouped.get(label) || 0) + count);
|
||||
});
|
||||
});
|
||||
return [...grouped.entries()]
|
||||
.map(([label, value]) => ({ label, value }))
|
||||
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label))
|
||||
.slice(0, sanitizePositiveInt(topN, 12, 1, 1000));
|
||||
};
|
||||
|
||||
export const aggregateDepartmentVisits = (usageLogs = []) => {
|
||||
const grouped = new Map();
|
||||
usageLogs.forEach((entry) => {
|
||||
const label = entry?.Location || 'Unknown';
|
||||
grouped.set(label, (grouped.get(label) || 0) + 1);
|
||||
});
|
||||
return [...grouped.entries()]
|
||||
.map(([label, value]) => ({ label, value }))
|
||||
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
export const uniqueCustomerIdsFromUsageLogs = (usageLogs = []) => {
|
||||
const ids = new Set();
|
||||
usageLogs.forEach((entry) => {
|
||||
const id = entry?.CustomerId;
|
||||
if (id !== null && id !== undefined && String(id).trim().length) {
|
||||
ids.add(String(id).trim());
|
||||
}
|
||||
});
|
||||
return [...ids];
|
||||
};
|
||||
|
||||
export const buildOverviewStats = (usageLogs = [], relatedOrders = {}) => {
|
||||
const customers = new Set();
|
||||
const departments = new Set();
|
||||
const relatedOrderIds = new Set();
|
||||
let latestWashAt = null;
|
||||
|
||||
usageLogs.forEach((entry) => {
|
||||
if (entry?.CustomerId) {
|
||||
customers.add(String(entry.CustomerId));
|
||||
}
|
||||
if (entry?.Location) {
|
||||
departments.add(String(entry.Location));
|
||||
}
|
||||
|
||||
const start = entry?.StartTime ? new Date(entry.StartTime) : null;
|
||||
if (start && !Number.isNaN(start.getTime())) {
|
||||
if (!latestWashAt || start.getTime() > latestWashAt.getTime()) {
|
||||
latestWashAt = start;
|
||||
}
|
||||
}
|
||||
|
||||
const linkedOrders = Array.isArray(relatedOrders?.[entry?.WashId])
|
||||
? relatedOrders[entry.WashId]
|
||||
: [];
|
||||
linkedOrders.forEach((orderId) => relatedOrderIds.add(String(orderId)));
|
||||
});
|
||||
|
||||
return {
|
||||
totalWashes: usageLogs.length,
|
||||
uniqueCustomers: customers.size,
|
||||
linkedOrders: relatedOrderIds.size,
|
||||
uniqueDepartments: departments.size,
|
||||
latestWashAt: latestWashAt ? latestWashAt.toISOString() : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
buildSystemSearchViewModel,
|
||||
humanizeEntityType,
|
||||
isKnownEntityType,
|
||||
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>(() => (
|
||||
isKnownEntityType(requestedEntityType.value)
|
||||
? 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 = '';
|
||||
|
||||
const fromState = readRouteStateRecord();
|
||||
if (fromState) {
|
||||
record.value = fromState;
|
||||
source.value = 'state';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entityType.value) {
|
||||
error.value = `Unknown entity type: ${requestedEntityType.value}`;
|
||||
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,152 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
async function suppressVueDevtoolsOverlay(page) {
|
||||
await page.addInitScript(() => {
|
||||
const STYLE_ID = "__e2e-hide-vue-devtools";
|
||||
|
||||
const apply = () => {
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = "#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
|
||||
const container = document.getElementById("__vue-devtools-container__");
|
||||
if (container) {
|
||||
container.style.display = "none";
|
||||
container.style.pointerEvents = "none";
|
||||
}
|
||||
};
|
||||
|
||||
apply();
|
||||
const observer = new MutationObserver(apply);
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function primeSuperuserSession(page) {
|
||||
const token = "superuser-e2e-token";
|
||||
await seedAuthenticatedState(page, token);
|
||||
await page.goto("/login");
|
||||
await page.evaluate(async (sessionToken) => {
|
||||
const sessionModule = await import("/src/components/session/token/SessionUser.vue");
|
||||
window.localStorage.setItem("token", sessionToken);
|
||||
sessionModule.SessionUser.token.value = sessionToken;
|
||||
sessionModule.SessionUser.authenticated.value = true;
|
||||
sessionModule.SessionUser.permissions.value = ["superuser", "user"];
|
||||
sessionModule.SessionUser.initiated.value = true;
|
||||
}, token);
|
||||
}
|
||||
|
||||
test.describe("Invoice distribution smoke", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await suppressVueDevtoolsOverlay(page);
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
invoiceDistribution: true
|
||||
});
|
||||
await primeSuperuserSession(page);
|
||||
});
|
||||
|
||||
test("@smoke overview loads and quick-open month action works", async ({ page }) => {
|
||||
await page.goto("/superuser/invoices?activeTab=distribution");
|
||||
|
||||
await expect(page.getByTestId("distribution-overview-page")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Departmental Distribution" })).toBeVisible();
|
||||
const openMonthButton = page.getByTestId("distribution-overview-open-month").first();
|
||||
await expect(openMonthButton).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const popupPromise = page.waitForEvent("popup", { timeout: 3000 }).catch(() => null);
|
||||
await openMonthButton.click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
if (popup) {
|
||||
await expect(popup).toHaveURL(/\/superuser\/invoices\/distribution\/\d+\/\d+/);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(/\/superuser\/invoices\/distribution\/\d+\/\d+/);
|
||||
});
|
||||
|
||||
test("@smoke monthly tabs keep URL query-state in sync", async ({ page }) => {
|
||||
await page.goto("/superuser/invoices/distribution/2026/3/customers?customerSearch=acme&customerSource=fixed_pricing&customerDepartment=Copenhagen&compareMode=line_by_line");
|
||||
|
||||
await expect(page.getByTestId("distribution-month-tabs")).toBeVisible();
|
||||
await expect(page).toHaveURL(/customerSearch=acme/);
|
||||
await expect(page).toHaveURL(/customerSource=fixed_pricing/);
|
||||
|
||||
await page.getByRole("tab", { name: /Departments/i }).click();
|
||||
await page.getByTestId("distribution-department-search").fill("Odense");
|
||||
await expect(page).toHaveURL(/departmentSearch=Odense/);
|
||||
|
||||
await page.getByRole("tab", { name: /Customers/i }).click();
|
||||
await page.getByTestId("distribution-customer-search").fill("Nordic");
|
||||
await expect(page).toHaveURL(/customerSearch=Nordic/);
|
||||
|
||||
await page.getByRole("tab", { name: /Compare/i }).click();
|
||||
await expect(page).toHaveURL(/\/compare/);
|
||||
await expect(page).toHaveURL(/compareMode=line_by_line/);
|
||||
});
|
||||
|
||||
test("@smoke compare flow shows progress and mismatch-first results", async ({ page }) => {
|
||||
await page.goto("/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
|
||||
|
||||
await page.getByTestId("distribution-compare-submit").click();
|
||||
const compareTable = page.getByTestId("distribution-compare-table");
|
||||
const progress = page.locator(".compare-progress");
|
||||
|
||||
await Promise.race([
|
||||
progress.waitFor({ state: "visible", timeout: 6_000 }).catch(() => null),
|
||||
compareTable.waitFor({ state: "visible", timeout: 10_000 })
|
||||
]);
|
||||
|
||||
await expect(compareTable).toBeVisible();
|
||||
await expect(compareTable.getByText("#101")).toBeVisible();
|
||||
await expect(compareTable.getByText("#102")).toBeVisible();
|
||||
|
||||
const firstDataRow = compareTable.locator("tbody tr").first();
|
||||
await expect(firstDataRow).toContainText("#101");
|
||||
});
|
||||
|
||||
test("@smoke mobile layout sanity keeps primary controls visible", async ({ page }) => {
|
||||
await page.goto("/superuser/invoices/distribution/2026/3/overview");
|
||||
|
||||
await expect(page.getByTestId("distribution-month-toolbar")).toBeVisible();
|
||||
await expect(page.getByTestId("distribution-month-prev")).toBeVisible();
|
||||
await expect(page.getByTestId("distribution-month-next")).toBeVisible();
|
||||
await expect(page.locator(".summary-ribbon:visible").first()).toBeVisible();
|
||||
await page.getByRole("tab", { name: /Departments/i }).click();
|
||||
await expect(page.locator(".table-container--scroll:visible").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("@smoke fallback mode keeps results and surfaces warning banners", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
invoiceDistribution: true,
|
||||
invoiceDistributionForceLegacyFallback: true,
|
||||
invoiceDistributionForceCompareFallback: true
|
||||
});
|
||||
await primeSuperuserSession(page);
|
||||
|
||||
await page.goto("/superuser/invoices?activeTab=distribution");
|
||||
await expect(
|
||||
page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.goto("/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
|
||||
await expect(
|
||||
page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("distribution-compare-submit").click();
|
||||
await expect(page.getByTestId("distribution-compare-table")).toBeVisible();
|
||||
await expect(
|
||||
page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
+464
-21
@@ -1,4 +1,4 @@
|
||||
const API_HOST = "**://api.truckwash.io/**";
|
||||
const API_HOST = /https?:\/\/(?:api\.truckwash\.io|localhost|127\.0\.0\.1)(?::\d+)?\/api\/.*/i;
|
||||
|
||||
function json(body, status = 200) {
|
||||
return {
|
||||
@@ -12,6 +12,8 @@ export async function mockApi(page, options = {}) {
|
||||
await page.route(API_HOST, async (route) => {
|
||||
const request = route.request();
|
||||
const url = request.url();
|
||||
const parsedUrl = new URL(url);
|
||||
const pathname = parsedUrl.pathname;
|
||||
const method = request.method();
|
||||
|
||||
if (url.includes("/auth/recaptcha/pre-check") && method === "GET") {
|
||||
@@ -52,33 +54,474 @@ export async function mockApi(page, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultSession = {
|
||||
id: 1,
|
||||
customer_number: 12345,
|
||||
group_id: 1,
|
||||
email: "e2e@example.com",
|
||||
phone: {
|
||||
number: "12345678",
|
||||
country_code: 45
|
||||
},
|
||||
notifications: {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: true,
|
||||
sms_notifications_enabled: false
|
||||
},
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
display_name: "E2E User",
|
||||
permissions: options.permissions || ["user"],
|
||||
economic_customer: []
|
||||
};
|
||||
|
||||
const sessionData = {
|
||||
...defaultSession,
|
||||
...(options.sessionData || {})
|
||||
};
|
||||
|
||||
if (options.permissions) {
|
||||
sessionData.permissions = options.permissions;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
id: 1,
|
||||
customer_number: 12345,
|
||||
group_id: 1,
|
||||
email: "e2e@example.com",
|
||||
phone: {
|
||||
number: "12345678",
|
||||
country_code: 45
|
||||
},
|
||||
notifications: {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: true,
|
||||
sms_notifications_enabled: false
|
||||
},
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
display_name: "E2E User",
|
||||
permissions: ["user"],
|
||||
economic_customer: []
|
||||
}
|
||||
data: sessionData
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.invoiceDistribution) {
|
||||
const monthFromDate = parsedUrl.searchParams.get("dateFrom");
|
||||
const monthNumber = monthFromDate ? Number(monthFromDate.split("-")[1]) : 1;
|
||||
const monthBase = Number.isFinite(monthNumber) ? monthNumber * 10 : 10;
|
||||
const forceDistributionLegacy = Boolean(options.invoiceDistributionForceLegacyFallback);
|
||||
const forceCompareLegacy = Boolean(options.invoiceDistributionForceCompareFallback);
|
||||
|
||||
if (pathname.endsWith("/orders") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
created_at: "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/superuser/invoicing/period") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
transactions: [
|
||||
{ amount: 100 + monthBase, booked: true, excluded: false },
|
||||
{ amount: 50 + monthBase, booked: true, excluded: false },
|
||||
{ amount: 25, booked: false, excluded: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/all") && method === "GET") {
|
||||
if (forceDistributionLegacy) {
|
||||
await route.fulfill(json({ message: "v2 distribution temporarily unavailable" }, 500));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
fixed_pricing: {
|
||||
customers: [
|
||||
{
|
||||
id: 10,
|
||||
customer_number: 1001,
|
||||
customer_name: "Acme Transport",
|
||||
requires_action: false,
|
||||
transactions: [{ id: 1, date: "2026-01-10T00:00:00.000Z", amount: 80 + monthBase, booked: true, department_id: 1, excluded: false }],
|
||||
meta: {
|
||||
fixed_pricing: {
|
||||
created_at: "2026-01-10T00:00:00.000Z",
|
||||
price: 80 + monthBase,
|
||||
original_price: 120 + monthBase,
|
||||
department_totals_relative: { 1: 40 + monthBase, 2: 40 }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
collective_results: {
|
||||
total_fixed_price: 80 + monthBase,
|
||||
total_department_totals_relative_parsed: {
|
||||
Copenhagen: 40 + monthBase,
|
||||
Odense: 40
|
||||
}
|
||||
},
|
||||
warnings: []
|
||||
},
|
||||
wash_subscriptions: {
|
||||
customers: [
|
||||
{
|
||||
id: 11,
|
||||
customer_number: 1002,
|
||||
customer_name: "Nordic Haul",
|
||||
requires_action: false,
|
||||
transactions: [{ id: 2, date: "2026-01-05T00:00:00.000Z", amount: 40 + monthBase, booked: true, department_id: 1, excluded: false }],
|
||||
meta: {
|
||||
wash_subscription: {
|
||||
created_at: "2026-01-05T00:00:00.000Z",
|
||||
price: 40 + monthBase,
|
||||
original_price: 55 + monthBase,
|
||||
department_totals_relative: { 1: 20 + monthBase, 2: 20 }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
collective_results: {
|
||||
total_subscription_price: 40 + monthBase,
|
||||
subscription_price_department_distribution_parsed: {
|
||||
Copenhagen: 20 + monthBase,
|
||||
Odense: 20
|
||||
}
|
||||
},
|
||||
warnings: []
|
||||
},
|
||||
customer_prices: {
|
||||
customers: [
|
||||
{
|
||||
id: 12,
|
||||
customer_number: 1003,
|
||||
customer_name: "Discount Fleet",
|
||||
requires_action: false,
|
||||
transactions: [{ id: 3, date: "2026-01-08T00:00:00.000Z", amount: 15 + monthBase, booked: true, department_id: 2, excluded: false }],
|
||||
meta: {
|
||||
customer_price: {
|
||||
created_at: "2026-01-08T00:00:00.000Z",
|
||||
price: 15 + monthBase,
|
||||
department_totals_relative: { 2: 15 + monthBase }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
collective_results: {
|
||||
total_customer_price: 15 + monthBase,
|
||||
customer_price_department_distribution_parsed: {
|
||||
Odense: 15 + monthBase
|
||||
}
|
||||
},
|
||||
warnings: []
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/fixed-pricing") && method === "GET") {
|
||||
if (forceDistributionLegacy) {
|
||||
await route.fulfill(json({ message: "v2 fixed pricing distribution temporarily unavailable" }, 500));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
customers: [
|
||||
{
|
||||
id: 10,
|
||||
customer_number: 1001,
|
||||
customer_name: "Acme Transport",
|
||||
requires_action: false,
|
||||
transactions: [{ id: 1, date: "2026-01-10T00:00:00.000Z", amount: 80 + monthBase, booked: true, department_id: 1, excluded: false }],
|
||||
meta: {
|
||||
fixed_pricing: {
|
||||
created_at: "2026-01-10T00:00:00.000Z",
|
||||
price: 80 + monthBase,
|
||||
original_price: 120 + monthBase,
|
||||
department_totals_relative: { 1: 40 + monthBase, 2: 40 }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
collective_results: {
|
||||
total_fixed_price: 80 + monthBase,
|
||||
total_department_totals_relative_parsed: {
|
||||
Copenhagen: 40 + monthBase,
|
||||
Odense: 40
|
||||
}
|
||||
},
|
||||
warnings: []
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/wash-subscriptions") && method === "GET") {
|
||||
if (forceDistributionLegacy) {
|
||||
await route.fulfill(json({ message: "v2 wash subscriptions distribution temporarily unavailable" }, 500));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
customers: [
|
||||
{
|
||||
id: 11,
|
||||
customer_number: 1002,
|
||||
customer_name: "Nordic Haul",
|
||||
requires_action: false,
|
||||
transactions: [{ id: 2, date: "2026-01-05T00:00:00.000Z", amount: 40 + monthBase, booked: true, department_id: 1, excluded: false }],
|
||||
meta: {
|
||||
wash_subscription: {
|
||||
created_at: "2026-01-05T00:00:00.000Z",
|
||||
price: 40 + monthBase,
|
||||
original_price: 55 + monthBase,
|
||||
department_totals_relative: { 1: 20 + monthBase, 2: 20 }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
collective_results: {
|
||||
total_subscription_price: 40 + monthBase,
|
||||
subscription_price_department_distribution_parsed: {
|
||||
Copenhagen: 20 + monthBase,
|
||||
Odense: 20
|
||||
}
|
||||
},
|
||||
warnings: []
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/customer-prices") && method === "GET") {
|
||||
if (forceDistributionLegacy) {
|
||||
await route.fulfill(json({ message: "v2 customer prices distribution temporarily unavailable" }, 500));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
customers: [
|
||||
{
|
||||
id: 12,
|
||||
customer_number: 1003,
|
||||
customer_name: "Discount Fleet",
|
||||
requires_action: false,
|
||||
transactions: [{ id: 3, date: "2026-01-08T00:00:00.000Z", amount: 15 + monthBase, booked: true, department_id: 2, excluded: false }],
|
||||
meta: {
|
||||
customer_price: {
|
||||
created_at: "2026-01-08T00:00:00.000Z",
|
||||
price: 15 + monthBase,
|
||||
department_totals_relative: { 2: 15 + monthBase }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
collective_results: {
|
||||
total_customer_price: 15 + monthBase,
|
||||
customer_price_department_distribution_parsed: {
|
||||
Odense: 15 + monthBase
|
||||
}
|
||||
},
|
||||
warnings: []
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/superuser/invoicing/period/distribution/fixed-pricing") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: 10,
|
||||
customer_number: 1001,
|
||||
customer_name: "Acme Transport",
|
||||
meta: {
|
||||
fixed_pricing: {
|
||||
created_at: "2026-01-10T00:00:00.000Z",
|
||||
price: 80 + monthBase,
|
||||
original_price: 120 + monthBase,
|
||||
department_totals_relative: { 1: 40 + monthBase, 2: 40 }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
includes: {
|
||||
collective_fixed_pricing_results: {
|
||||
total_fixed_price: 80 + monthBase,
|
||||
total_department_totals_relative_parsed: {
|
||||
Copenhagen: 40 + monthBase,
|
||||
Odense: 40
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/superuser/invoicing/period/distribution/wash-subscriptions") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: 11,
|
||||
customer_number: 1002,
|
||||
customer_name: "Nordic Haul",
|
||||
meta: {
|
||||
wash_subscription: {
|
||||
created_at: "2026-01-05T00:00:00.000Z",
|
||||
price: 40 + monthBase,
|
||||
original_price: 55 + monthBase,
|
||||
department_totals_relative: { 1: 20 + monthBase, 2: 20 }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
includes: {
|
||||
collective_subscription_results: {
|
||||
total_subscription_price: 40 + monthBase,
|
||||
subscription_price_department_distribution_parsed: {
|
||||
Copenhagen: 20 + monthBase,
|
||||
Odense: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/departments") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{ id: 1, name: "Copenhagen" },
|
||||
{ id: 2, name: "Odense" }
|
||||
]
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/collected-invoices") && method === "GET") {
|
||||
const invoices = [101, 102, 103, 104, 105, 106].map((invoiceId, index) => ({
|
||||
id: invoiceId,
|
||||
customer_number: 1001 + index,
|
||||
customer_name: `Customer ${invoiceId}`,
|
||||
total_net_amount: 100 + (index * 10)
|
||||
}));
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: invoices
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/collected-invoices/economic/compare") && method === "GET") {
|
||||
const invoiceId = Number(parsedUrl.searchParams.get("collected_invoice_id") || 0);
|
||||
const mismatch = invoiceId === 101 || invoiceId === 103;
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
collected_invoice_id: invoiceId,
|
||||
warnings: mismatch ? ["Line mismatch found"] : [],
|
||||
internal_total: mismatch ? 150 : 120,
|
||||
booked_total: mismatch ? 145 : 120,
|
||||
draft_total: null,
|
||||
difference: mismatch ? 5 : 0,
|
||||
order_ids: mismatch ? [1, 2] : [3]
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/collected-invoices/economic/v2/compare/bulk") && method === "POST") {
|
||||
if (forceCompareLegacy) {
|
||||
await route.fulfill(json({ message: "v2 compare bulk temporarily unavailable" }, 500));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const ids = Array.isArray(body.collected_invoice_ids) ? body.collected_invoice_ids : [];
|
||||
const results = ids.map((invoiceId) => {
|
||||
const mismatch = Number(invoiceId) === 101 || Number(invoiceId) === 103;
|
||||
const internalTotal = mismatch ? 150 : 120;
|
||||
const targetTotal = mismatch ? 145 : 120;
|
||||
|
||||
return {
|
||||
collected_invoice_id: Number(invoiceId),
|
||||
warnings: mismatch ? ["Line mismatch found"] : [],
|
||||
details: {
|
||||
order_ids: mismatch ? [1, 2] : [3],
|
||||
customer: {
|
||||
internal_customer_number: 4000 + Number(invoiceId),
|
||||
name: `Customer ${invoiceId}`
|
||||
},
|
||||
internal: {
|
||||
normalized: {
|
||||
totals: {
|
||||
net_total: internalTotal,
|
||||
billable_line_count: mismatch ? 2 : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
comparison: {
|
||||
totals: {
|
||||
internal_net_total: internalTotal
|
||||
},
|
||||
targets: {
|
||||
booked: {
|
||||
target: "booked",
|
||||
status: mismatch ? "partial_mismatch" : "exact_match",
|
||||
overall_match: !mismatch,
|
||||
totals: {
|
||||
target_net_total: targetTotal
|
||||
},
|
||||
mismatch_reasons: mismatch ? ["department_total_mismatch"] : [],
|
||||
warnings: mismatch ? ["Line mismatch found"] : [],
|
||||
lines: {
|
||||
summary: {
|
||||
internal_billable_count: mismatch ? 2 : 1,
|
||||
target_billable_count: mismatch ? 2 : 1,
|
||||
mismatch_count: mismatch ? 1 : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
warnings: mismatch ? ["comparison warning"] : []
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
requested: ids.length,
|
||||
compared: ids.length,
|
||||
failed: 0,
|
||||
results,
|
||||
errors: []
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.fallbackPassthrough) {
|
||||
await route.fallback();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/components/session/token/SessionUser.vue', () => {
|
||||
const request = vi.fn();
|
||||
const parseErrorMessage = vi.fn((error) => error?.message || String(error || 'Unknown error'));
|
||||
|
||||
return {
|
||||
SessionUser: {
|
||||
request,
|
||||
functions: {
|
||||
parseErrorMessage,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import {
|
||||
compareCollectedInvoicesForMonth,
|
||||
fetchMonthData,
|
||||
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js';
|
||||
|
||||
const buildInvoice = (id) => ({
|
||||
id,
|
||||
customer_number: 2000 + id,
|
||||
customer_name: `Customer ${id}`,
|
||||
total_net_amount: 100,
|
||||
});
|
||||
|
||||
const buildV2Category = (amount) => ({
|
||||
customers: [
|
||||
{
|
||||
id: 1,
|
||||
customer_number: 5001,
|
||||
customer_name: 'ACME',
|
||||
requires_action: false,
|
||||
transactions: [{ id: 1, amount, booked: true, excluded: false, department_id: 1, date: '2026-03-01T00:00:00Z' }],
|
||||
meta: {
|
||||
fixed_pricing: {
|
||||
price: amount,
|
||||
department_totals_relative: { 1: amount },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
collective_results: {
|
||||
total_amount: amount,
|
||||
department_distribution_parsed: { Ops: amount },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const buildV2CompareResult = (invoiceId, { internalTotal = 100, bookedTotal = 100, mismatch = false } = {}) => ({
|
||||
collected_invoice_id: invoiceId,
|
||||
warnings: mismatch ? ['Top level warning'] : [],
|
||||
details: {
|
||||
order_ids: mismatch ? [1, 2] : [1],
|
||||
customer: {
|
||||
internal_customer_number: 7000 + invoiceId,
|
||||
name: `Customer ${invoiceId}`,
|
||||
},
|
||||
internal: {
|
||||
normalized: {
|
||||
totals: {
|
||||
net_total: internalTotal,
|
||||
billable_line_count: mismatch ? 2 : 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
comparison: {
|
||||
totals: {
|
||||
internal_net_total: internalTotal,
|
||||
},
|
||||
targets: {
|
||||
booked: {
|
||||
target: 'booked',
|
||||
status: mismatch ? 'partial_mismatch' : 'exact_match',
|
||||
overall_match: !mismatch,
|
||||
totals: {
|
||||
target_net_total: bookedTotal,
|
||||
difference: internalTotal - bookedTotal,
|
||||
},
|
||||
mismatch_reasons: mismatch ? ['department_total_mismatch'] : [],
|
||||
warnings: mismatch ? ['Line mismatch'] : [],
|
||||
lines: {
|
||||
summary: {
|
||||
internal_billable_count: mismatch ? 2 : 1,
|
||||
target_billable_count: mismatch ? 2 : 1,
|
||||
mismatch_count: mismatch ? 1 : 0,
|
||||
},
|
||||
diff: [],
|
||||
},
|
||||
departments: {
|
||||
matches: !mismatch,
|
||||
diff: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
|
||||
describe('invoice distribution api adapter', () => {
|
||||
let consoleWarnSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
SessionUser.request.mockReset();
|
||||
SessionUser.functions.parseErrorMessage.mockReset();
|
||||
SessionUser.functions.parseErrorMessage.mockImplementation((error) => error?.message || String(error || 'Unknown error'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it('uses v2 all endpoint for month distribution when available', async () => {
|
||||
SessionUser.request.mockImplementation(async (endpoint) => {
|
||||
if (endpoint === '/superuser/invoicing/period') {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
transactions: [
|
||||
{ amount: 200, booked: true, excluded: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === '/superuser/invoicing/period/distribution/v2/all') {
|
||||
return {
|
||||
data: {
|
||||
fixed_pricing: buildV2Category(80),
|
||||
wash_subscriptions: buildV2Category(70),
|
||||
customer_prices: buildV2Category(50),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
|
||||
const result = await fetchMonthData(2026, 3);
|
||||
expect(result.fallback.usedLegacyDistribution).toBe(false);
|
||||
expect(result.fallback.usedSplitV2Distribution).toBe(false);
|
||||
expect(result.summary.distributionAmount).toBe(200);
|
||||
expect(SessionUser.request).toHaveBeenCalledWith('/superuser/invoicing/period/distribution/v2/all', 'GET', expect.any(Object));
|
||||
});
|
||||
|
||||
it('falls back to v2 split endpoints when v2 all fails', async () => {
|
||||
SessionUser.request.mockImplementation(async (endpoint) => {
|
||||
if (endpoint === '/superuser/invoicing/period') {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
types: {
|
||||
all: [{ transactions: [{ amount: 120, booked: true, excluded: false }] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === '/superuser/invoicing/period/distribution/v2/all') {
|
||||
throw new Error('v2 all unavailable');
|
||||
}
|
||||
|
||||
if (endpoint === '/superuser/invoicing/period/distribution/v2/fixed-pricing') {
|
||||
return { data: buildV2Category(40) };
|
||||
}
|
||||
|
||||
if (endpoint === '/superuser/invoicing/period/distribution/v2/wash-subscriptions') {
|
||||
return { data: buildV2Category(30) };
|
||||
}
|
||||
|
||||
if (endpoint === '/superuser/invoicing/period/distribution/v2/customer-prices') {
|
||||
return { data: buildV2Category(20) };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
|
||||
const result = await fetchMonthData(2026, 3);
|
||||
expect(result.fallback.usedLegacyDistribution).toBe(false);
|
||||
expect(result.fallback.usedSplitV2Distribution).toBe(true);
|
||||
expect(result.fallback.distributionFallbackReason).toContain('v2 all unavailable');
|
||||
expect(result.summary.distributionAmount).toBe(90);
|
||||
});
|
||||
|
||||
it('falls back to legacy distribution endpoints when all v2 paths fail', async () => {
|
||||
SessionUser.request.mockImplementation(async (endpoint) => {
|
||||
if (endpoint === '/superuser/invoicing/period') {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
types: {
|
||||
all: [{ transactions: [{ amount: 120, booked: true, excluded: false }] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
endpoint === '/superuser/invoicing/period/distribution/v2/all'
|
||||
|| endpoint === '/superuser/invoicing/period/distribution/v2/fixed-pricing'
|
||||
|| endpoint === '/superuser/invoicing/period/distribution/v2/wash-subscriptions'
|
||||
|| endpoint === '/superuser/invoicing/period/distribution/v2/customer-prices'
|
||||
) {
|
||||
throw new Error(`${endpoint} unavailable`);
|
||||
}
|
||||
|
||||
if (endpoint === '/superuser/invoicing/period/distribution/fixed-pricing') {
|
||||
return {
|
||||
data: {
|
||||
customers: [
|
||||
{
|
||||
id: 1,
|
||||
customer_number: 1001,
|
||||
customer_name: 'Legacy Fixed',
|
||||
meta: { fixed_pricing: { price: 50 } },
|
||||
},
|
||||
],
|
||||
collective_results: {
|
||||
total_fixed_price: 50,
|
||||
total_department_totals_relative_parsed: { Ops: 50 },
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === '/superuser/invoicing/period/distribution/wash-subscriptions') {
|
||||
return {
|
||||
data: {
|
||||
customers: [
|
||||
{
|
||||
id: 2,
|
||||
customer_number: 1002,
|
||||
customer_name: 'Legacy Subscription',
|
||||
meta: { wash_subscription: { price: 25 } },
|
||||
},
|
||||
],
|
||||
collective_results: {
|
||||
total_subscription_price: 25,
|
||||
subscription_price_department_distribution_parsed: { Ops: 25 },
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
|
||||
const result = await fetchMonthData(2026, 3);
|
||||
expect(result.fallback.usedLegacyDistribution).toBe(true);
|
||||
expect(result.fallback.distributionFallbackReason).toContain('/superuser/invoicing/period/distribution/v2/all unavailable');
|
||||
expect(result.summary.fixedPricingAmount).toBe(50);
|
||||
expect(result.summary.subscriptionAmount).toBe(25);
|
||||
});
|
||||
|
||||
it('chunks v2 bulk compare requests to max 200 ids', async () => {
|
||||
const invoices = Array.from({ length: 250 }, (_, index) => buildInvoice(index + 1));
|
||||
|
||||
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
|
||||
if (endpoint === '/collected-invoices' && method === 'GET') {
|
||||
const page = Number(params?.page || 1);
|
||||
const perPage = 100;
|
||||
const start = (page - 1) * perPage;
|
||||
const data = invoices.slice(start, start + perPage);
|
||||
return {
|
||||
data: {
|
||||
data,
|
||||
meta: {
|
||||
pagination: {
|
||||
total: invoices.length,
|
||||
per_page: perPage,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
|
||||
const ids = params?.collected_invoice_ids || [];
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
requested: ids.length,
|
||||
compared: ids.length,
|
||||
failed: 0,
|
||||
results: ids.map((id) => buildV2CompareResult(id)),
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
|
||||
const result = await compareCollectedInvoicesForMonth({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
mode: 'invoice_total',
|
||||
batchSize: 500,
|
||||
});
|
||||
|
||||
const bulkCalls = SessionUser.request.mock.calls.filter(([endpoint]) => endpoint === '/collected-invoices/economic/v2/compare/bulk');
|
||||
expect(bulkCalls).toHaveLength(2);
|
||||
expect(bulkCalls[0][2].collected_invoice_ids).toHaveLength(200);
|
||||
expect(bulkCalls[1][2].collected_invoice_ids).toHaveLength(50);
|
||||
expect(result.invoiceCount).toBe(250);
|
||||
expect(result.fallback.usedLegacyCompare).toBe(false);
|
||||
});
|
||||
|
||||
it('emits incremental compare progress and row chunks while v2 compare runs', async () => {
|
||||
const invoices = [buildInvoice(1), buildInvoice(2), buildInvoice(3), buildInvoice(4), buildInvoice(5)];
|
||||
const onRows = vi.fn();
|
||||
const onProgress = vi.fn();
|
||||
|
||||
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
|
||||
if (endpoint === '/collected-invoices' && method === 'GET') {
|
||||
return {
|
||||
data: {
|
||||
data: invoices,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
|
||||
const ids = params?.collected_invoice_ids || [];
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
requested: ids.length,
|
||||
compared: ids.length,
|
||||
failed: 0,
|
||||
results: ids.map((id) => buildV2CompareResult(id)),
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
|
||||
const result = await compareCollectedInvoicesForMonth({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
mode: 'invoice_total',
|
||||
batchSize: 2,
|
||||
onRows,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
expect(result.rows).toHaveLength(5);
|
||||
expect(onRows).toHaveBeenCalledTimes(5);
|
||||
expect(onRows.mock.calls.map(([rows]) => rows.length)).toEqual([1, 1, 1, 1, 1]);
|
||||
expect(onProgress.mock.calls.map(([progress]) => `${progress.processed}/${progress.total}`)).toEqual([
|
||||
'0/5',
|
||||
'1/5',
|
||||
'2/5',
|
||||
'3/5',
|
||||
'4/5',
|
||||
'5/5',
|
||||
]);
|
||||
});
|
||||
|
||||
it('recovers missing bulk compare rows via v2 single endpoint', async () => {
|
||||
const invoices = [buildInvoice(101), buildInvoice(102)];
|
||||
|
||||
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
|
||||
if (endpoint === '/collected-invoices' && method === 'GET') {
|
||||
return {
|
||||
data: {
|
||||
data: invoices,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
requested: params?.collected_invoice_ids?.length || 0,
|
||||
compared: 1,
|
||||
failed: 1,
|
||||
results: [buildV2CompareResult(101)],
|
||||
errors: [{ collected_invoice_id: 102, error: 'temporary mismatch in bulk compare' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === '/collected-invoices/economic/v2/compare' && method === 'GET') {
|
||||
return {
|
||||
data: {
|
||||
data: buildV2CompareResult(102, { mismatch: true, internalTotal: 110, bookedTotal: 90 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
|
||||
const result = await compareCollectedInvoicesForMonth({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
mode: 'line_by_line',
|
||||
});
|
||||
|
||||
expect(result.fallback.usedLegacyCompare).toBe(false);
|
||||
expect(result.fallback.usedSingleCompareFallback).toBe(true);
|
||||
expect(result.fallback.compareFallbackReason).toContain('temporary mismatch in bulk compare');
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows.some((row) => row.invoiceId === 102)).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to legacy compare when v2 bulk compare fails', async () => {
|
||||
const invoices = [buildInvoice(101), buildInvoice(102)];
|
||||
|
||||
SessionUser.request.mockImplementation(async (endpoint, method, params) => {
|
||||
if (endpoint === '/collected-invoices' && method === 'GET') {
|
||||
return {
|
||||
data: {
|
||||
data: invoices,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
|
||||
throw new Error('v2 compare failed');
|
||||
}
|
||||
|
||||
if (endpoint === '/collected-invoices/economic/compare' && method === 'GET') {
|
||||
const invoiceId = Number(params?.collected_invoice_id);
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
collected_invoice_id: invoiceId,
|
||||
internal_total: 100,
|
||||
booked_total: 95,
|
||||
difference: 5,
|
||||
warnings: [],
|
||||
order_ids: [invoiceId],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
|
||||
const result = await compareCollectedInvoicesForMonth({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
mode: 'invoice_total',
|
||||
legacyBatchSize: 10,
|
||||
});
|
||||
|
||||
const legacyCalls = SessionUser.request.mock.calls.filter(([endpoint]) => endpoint === '/collected-invoices/economic/compare');
|
||||
expect(legacyCalls).toHaveLength(2);
|
||||
expect(result.fallback.usedLegacyCompare).toBe(true);
|
||||
expect(result.fallback.compareFallbackReason).toContain('v2 compare failed');
|
||||
expect(result.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates mismatch fallback row when both bulk and single v2 data are missing', async () => {
|
||||
const invoices = [buildInvoice(301)];
|
||||
|
||||
SessionUser.request.mockImplementation(async (endpoint, method) => {
|
||||
if (endpoint === '/collected-invoices' && method === 'GET') {
|
||||
return { data: { data: invoices } };
|
||||
}
|
||||
|
||||
if (endpoint === '/collected-invoices/economic/v2/compare/bulk' && method === 'POST') {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
requested: 1,
|
||||
compared: 0,
|
||||
failed: 1,
|
||||
results: [],
|
||||
errors: [{ collected_invoice_id: 301, error: 'no bulk result' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endpoint === '/collected-invoices/economic/v2/compare' && method === 'GET') {
|
||||
throw new Error('single compare down');
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||
});
|
||||
|
||||
const result = await compareCollectedInvoicesForMonth({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
mode: 'invoice_total',
|
||||
});
|
||||
|
||||
expect(result.fallback.usedLegacyCompare).toBe(false);
|
||||
expect(result.fallback.usedSingleCompareFallback).toBe(true);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].status).toBe('mismatch');
|
||||
expect(result.rows[0].warnings.join(' ')).toContain('single compare down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildDistributionQueryState,
|
||||
getMonthRange,
|
||||
buildMonthSummary,
|
||||
buildDepartmentAllocations,
|
||||
buildCustomerAllocationRows,
|
||||
buildMonthComparison,
|
||||
buildSourceComposition,
|
||||
normalizeDistributionQueryState,
|
||||
normalizeCompareResult,
|
||||
aggregateCompareRows,
|
||||
sortCompareRows,
|
||||
sortMonthSummaries,
|
||||
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js';
|
||||
|
||||
describe('invoice distribution calculations', () => {
|
||||
it('builds month ranges from first month to current month', () => {
|
||||
const result = getMonthRange(new Date('2026-01-10T00:00:00Z'), new Date('2026-03-20T00:00:00Z'), true);
|
||||
expect(result.map((item) => item.key)).toEqual(['2026-03', '2026-02', '2026-01']);
|
||||
});
|
||||
|
||||
it('builds month summary with booked and source split', () => {
|
||||
const summary = buildMonthSummary({
|
||||
year: 2026,
|
||||
month: 2,
|
||||
periodData: {
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
transactions: [
|
||||
{ amount: 100, booked: true, excluded: false },
|
||||
{ amount: 25, booked: false, excluded: false },
|
||||
{ amount: 30, booked: true, excluded: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
fixedDistribution: {
|
||||
includes: {
|
||||
collective_fixed_pricing_results: {
|
||||
total_fixed_price: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptionDistribution: {
|
||||
includes: {
|
||||
collective_subscription_results: {
|
||||
total_subscription_price: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
customerPriceDistribution: {
|
||||
collective_results: {
|
||||
total_customer_price: 15,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary.bookedAmount).toBe(100);
|
||||
expect(summary.fixedPricingAmount).toBe(40);
|
||||
expect(summary.subscriptionAmount).toBe(20);
|
||||
expect(summary.customerPriceAmount).toBe(15);
|
||||
expect(summary.distributionAmount).toBe(75);
|
||||
expect(summary.otherBookedAmount).toBe(25);
|
||||
expect(summary.totalAmount).toBe(175);
|
||||
});
|
||||
|
||||
it('builds department allocations with percentages and booked estimate', () => {
|
||||
const rows = buildDepartmentAllocations({
|
||||
departments: [
|
||||
{ id: 1, name: 'Alpha' },
|
||||
{ id: 2, name: 'Beta' },
|
||||
],
|
||||
bookedAmount: 300,
|
||||
fixedDistribution: {
|
||||
includes: {
|
||||
collective_fixed_pricing_results: {
|
||||
total_department_totals_relative_parsed: {
|
||||
Alpha: 60,
|
||||
Beta: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptionDistribution: {
|
||||
includes: {
|
||||
collective_subscription_results: {
|
||||
subscription_price_department_distribution_parsed: {
|
||||
Alpha: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
customerPriceDistribution: {
|
||||
collective_results: {
|
||||
customer_price_department_distribution_parsed: {
|
||||
Beta: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].departmentName).toBe('Alpha');
|
||||
expect(rows[0].totalAmount).toBe(90);
|
||||
expect(rows[0].sharePercent).toBe(64.29);
|
||||
expect(rows[0].estimatedBookedAllocation).toBe(192.86);
|
||||
});
|
||||
|
||||
it('builds customer rows and respects creation date filter', () => {
|
||||
const rows = buildCustomerAllocationRows({
|
||||
year: 2026,
|
||||
month: 2,
|
||||
departments: [{ id: 1, name: 'Alpha' }],
|
||||
fixedDistribution: {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
customer_number: 10,
|
||||
customer_name: 'Customer A',
|
||||
meta: {
|
||||
fixed_pricing: {
|
||||
created_at: '2026-02-01T00:00:00Z',
|
||||
price: 100,
|
||||
original_price: 140,
|
||||
department_totals_relative: { 1: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
customer_number: 20,
|
||||
customer_name: 'Future Customer',
|
||||
meta: {
|
||||
fixed_pricing: {
|
||||
created_at: '2026-03-01T00:00:00Z',
|
||||
price: 100,
|
||||
original_price: 140,
|
||||
department_totals_relative: { 1: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
subscriptionDistribution: {
|
||||
data: [],
|
||||
},
|
||||
customerPriceDistribution: {
|
||||
customers: [
|
||||
{
|
||||
id: 7,
|
||||
customer_number: 99,
|
||||
customer_name: 'Discount Customer',
|
||||
transactions: [{ id: 1, amount: 22, booked: true, excluded: false, date: '2026-02-05T00:00:00Z' }],
|
||||
meta: {
|
||||
customer_price: {
|
||||
created_at: '2026-02-05T00:00:00Z',
|
||||
price: 22,
|
||||
department_totals_relative: { 1: 22 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].customerName).toBe('Customer A');
|
||||
expect(rows[0].departmentAllocations).toEqual({ Alpha: 100 });
|
||||
expect(rows[1].source).toBe('customer_prices');
|
||||
});
|
||||
|
||||
it('builds month-over-month comparison deltas', () => {
|
||||
const comparison = buildMonthComparison(
|
||||
{ totalAmount: 200, bookedAmount: 140, distributionAmount: 60 },
|
||||
{ totalAmount: 100, bookedAmount: 120, distributionAmount: 30 }
|
||||
);
|
||||
|
||||
expect(comparison.totalAmount.delta).toBe(100);
|
||||
expect(comparison.bookedAmount.delta).toBe(20);
|
||||
expect(comparison.distributionAmount.delta).toBe(30);
|
||||
});
|
||||
|
||||
it('normalizes and serializes distribution query state', () => {
|
||||
const normalized = normalizeDistributionQueryState({
|
||||
compareMonth: '2026-02',
|
||||
compareMode: 'line_by_line',
|
||||
customerSearch: ' acme ',
|
||||
customerSource: 'customer_prices',
|
||||
customerDepartment: 'Ops',
|
||||
departmentSearch: ' nord ',
|
||||
});
|
||||
|
||||
expect(normalized).toEqual({
|
||||
compareMonth: '2026-02',
|
||||
compareMode: 'line_by_line',
|
||||
customerSearch: 'acme',
|
||||
customerSource: 'customer_prices',
|
||||
customerDepartment: 'Ops',
|
||||
departmentSearch: 'nord',
|
||||
});
|
||||
|
||||
const serialized = buildDistributionQueryState(normalized);
|
||||
expect(serialized).toEqual({
|
||||
compareMonth: '2026-02',
|
||||
compareMode: 'line_by_line',
|
||||
customerSearch: 'acme',
|
||||
customerSource: 'customer_prices',
|
||||
customerDepartment: 'Ops',
|
||||
departmentSearch: 'nord',
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts month summaries and compare rows for decision-first tables', () => {
|
||||
const sortedMonths = sortMonthSummaries(
|
||||
[
|
||||
{ key: '2026-01', year: 2026, month: 1, totalAmount: 10 },
|
||||
{ key: '2026-03', year: 2026, month: 3, totalAmount: 40 },
|
||||
{ key: '2026-02', year: 2026, month: 2, totalAmount: 20 },
|
||||
],
|
||||
'month',
|
||||
'desc'
|
||||
);
|
||||
expect(sortedMonths.map((row) => row.key)).toEqual(['2026-03', '2026-02', '2026-01']);
|
||||
|
||||
const sortedTotals = sortMonthSummaries(sortedMonths, 'total', 'asc');
|
||||
expect(sortedTotals.map((row) => row.totalAmount)).toEqual([10, 20, 40]);
|
||||
|
||||
const sortedCompare = sortCompareRows([
|
||||
{ invoiceId: 3, status: 'ok', warningCount: 0, difference: 0 },
|
||||
{ invoiceId: 2, status: 'ok', warningCount: 2, difference: 4 },
|
||||
{ invoiceId: 1, status: 'mismatch', warningCount: 1, difference: 10 },
|
||||
]);
|
||||
expect(sortedCompare.map((row) => row.invoiceId)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('builds source composition percentages for KPI cues', () => {
|
||||
const composition = buildSourceComposition({
|
||||
fixedPricingAmount: 60,
|
||||
subscriptionAmount: 40,
|
||||
customerPriceAmount: 20,
|
||||
distributionAmount: 120,
|
||||
});
|
||||
expect(composition.fixedPercent).toBe(50);
|
||||
expect(composition.subscriptionPercent).toBe(33.33);
|
||||
expect(composition.customerPricePercent).toBe(16.67);
|
||||
});
|
||||
|
||||
it('normalizes compare payloads for both modes and aggregates', () => {
|
||||
const okRow = normalizeCompareResult(
|
||||
{ id: 1, customer_name: 'A', total_net_amount: 100 },
|
||||
{
|
||||
internal_total: 100,
|
||||
booked_total: 100,
|
||||
difference: 0,
|
||||
warnings: [],
|
||||
order_ids: [1, 2],
|
||||
},
|
||||
'invoice_total'
|
||||
);
|
||||
|
||||
const mismatchRow = normalizeCompareResult(
|
||||
{ id: 2, customer_name: 'B', total_net_amount: 100 },
|
||||
{
|
||||
internal_total: 100,
|
||||
booked_total: 90,
|
||||
difference: 10,
|
||||
warnings: ['Line mismatch'],
|
||||
order_ids: [3],
|
||||
},
|
||||
'line_by_line'
|
||||
);
|
||||
|
||||
const v2Row = normalizeCompareResult(
|
||||
{ id: 3, customer_name: 'C', total_net_amount: 200 },
|
||||
{
|
||||
collected_invoice_id: 3,
|
||||
warnings: ['Top-level warning'],
|
||||
details: {
|
||||
order_ids: [10, 20],
|
||||
internal: {
|
||||
normalized: {
|
||||
totals: {
|
||||
net_total: 200,
|
||||
billable_line_count: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
name: 'Customer V2',
|
||||
},
|
||||
},
|
||||
comparison: {
|
||||
totals: {
|
||||
internal_net_total: 200,
|
||||
},
|
||||
targets: {
|
||||
booked: {
|
||||
status: 'partial_mismatch',
|
||||
overall_match: false,
|
||||
totals: {
|
||||
target_net_total: 180,
|
||||
},
|
||||
mismatch_reasons: ['department_total_mismatch'],
|
||||
lines: {
|
||||
summary: {
|
||||
mismatch_count: 1,
|
||||
target_billable_count: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
warnings: ['comparison warning'],
|
||||
},
|
||||
},
|
||||
'line_by_line'
|
||||
);
|
||||
|
||||
expect(okRow.status).toBe('ok');
|
||||
expect(mismatchRow.status).toBe('mismatch');
|
||||
expect(mismatchRow.lineMismatchCount).toBe(1);
|
||||
expect(v2Row.status).toBe('mismatch');
|
||||
expect(v2Row.bookedTotal).toBe(180);
|
||||
expect(v2Row.difference).toBe(20);
|
||||
expect(v2Row.mismatchReasons).toContain('department_total_mismatch');
|
||||
expect(v2Row.lineMismatchCount).toBe(1);
|
||||
|
||||
const aggregate = aggregateCompareRows([okRow, mismatchRow, v2Row]);
|
||||
expect(aggregate.total).toBe(3);
|
||||
expect(aggregate.mismatches).toBe(2);
|
||||
expect(aggregate.warningCount).toBe(4);
|
||||
expect(aggregate.totalDifference).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
const locales = {
|
||||
da: JSON.parse(readFileSync(join(root, 'src/i18n/locales/da.json'), 'utf8')),
|
||||
en: JSON.parse(readFileSync(join(root, 'src/i18n/locales/en.json'), 'utf8')),
|
||||
sv: JSON.parse(readFileSync(join(root, 'src/i18n/locales/sv.json'), 'utf8')),
|
||||
de: JSON.parse(readFileSync(join(root, 'src/i18n/locales/de.json'), 'utf8')),
|
||||
no: JSON.parse(readFileSync(join(root, 'src/i18n/locales/no.json'), 'utf8')),
|
||||
};
|
||||
|
||||
const flattenKeys = (value, prefix = '') => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return [prefix];
|
||||
}
|
||||
|
||||
const keys = [];
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
const nestedPrefix = prefix ? `${prefix}.${key}` : key;
|
||||
keys.push(...flattenKeys(nestedValue, nestedPrefix));
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
describe('invoice distribution i18n coverage', () => {
|
||||
it('defines distribution namespace in every locale', () => {
|
||||
for (const [locale, data] of Object.entries(locales)) {
|
||||
expect(data.superuser_invoice_distribution, `missing namespace in ${locale}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the same translation key set across locales', () => {
|
||||
const baselineKeys = flattenKeys(locales.en.superuser_invoice_distribution)
|
||||
.sort();
|
||||
|
||||
for (const [locale, data] of Object.entries(locales)) {
|
||||
const localeKeys = flattenKeys(data.superuser_invoice_distribution).sort();
|
||||
expect(localeKeys, `mismatched key set in ${locale}`).toEqual(baselineKeys);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes required distribution keys used by views', () => {
|
||||
const requiredKeys = [
|
||||
'tab_label',
|
||||
'overview.title',
|
||||
'overview.subtitle',
|
||||
'monthly_page.title',
|
||||
'monthly_page.toolbar_month',
|
||||
'actions.reload',
|
||||
'actions.open_month',
|
||||
'actions.try_again',
|
||||
'actions.reset_filters',
|
||||
'tabs.overview',
|
||||
'tabs.departments',
|
||||
'tabs.customers',
|
||||
'tabs.compare',
|
||||
'compare.mode',
|
||||
'compare.modes.invoice_total',
|
||||
'compare.modes.line_by_line',
|
||||
'compare.run',
|
||||
'compare.run_short',
|
||||
'compare.progress',
|
||||
'compare.warning_details',
|
||||
'compare.status_label',
|
||||
'compare.status.ok',
|
||||
'compare.status.mismatch',
|
||||
'errors.overview_load_failed',
|
||||
'errors.month_load_failed',
|
||||
'errors.compare_failed',
|
||||
'warnings.legacy_distribution_fallback',
|
||||
'warnings.legacy_distribution_month',
|
||||
'warnings.legacy_compare_fallback',
|
||||
'sections.trend',
|
||||
'metrics.customer_prices',
|
||||
'table.customer_prices',
|
||||
'table.source_mix',
|
||||
'aria.month_table_caption',
|
||||
'aria.compare_table_caption'
|
||||
];
|
||||
|
||||
const availableKeys = new Set(flattenKeys(locales.en.superuser_invoice_distribution));
|
||||
requiredKeys.forEach((key) => {
|
||||
expect(availableKeys.has(key)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
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 invoicesViewSource = readFileSync(join(root, 'src/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue'), 'utf8');
|
||||
const distributionOverviewSource = readFileSync(join(root, 'src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue'), 'utf8');
|
||||
const distributionMonthSource = readFileSync(join(root, 'src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue'), 'utf8');
|
||||
const distributionApiSource = readFileSync(join(root, 'src/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js'), 'utf8');
|
||||
const distributionChartSource = readFileSync(join(root, 'src/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionTrendChart.vue'), 'utf8');
|
||||
const distributionSkeletonSource = readFileSync(join(root, 'src/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionSkeleton.vue'), 'utf8');
|
||||
|
||||
describe('superuser invoice distribution routing', () => {
|
||||
it('keeps monthly distribution routes and route contract unchanged', () => {
|
||||
expect(routerSource).toContain("name: 'collectedorderinvoicesdistribution'");
|
||||
expect(routerSource).toContain("path: '/superuser/invoices/distribution/:year/:month'");
|
||||
expect(routerSource).toContain("name: 'collectedorderinvoicesdistributiontab'");
|
||||
expect(routerSource).toContain("path: '/superuser/invoices/distribution/:year/:month/:tab'");
|
||||
expect(routerSource).toContain('component: InvoiceDistributionMonthView');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollectedOrderInvoices distribution tab contract', () => {
|
||||
it('keeps Distribution tab wired to overview component', () => {
|
||||
expect(invoicesViewSource).toContain("slot: 'distribution'");
|
||||
expect(invoicesViewSource).toContain("name: t('superuser_invoice_distribution.tab_label')");
|
||||
expect(invoicesViewSource).toContain("case 'distribution':");
|
||||
expect(invoicesViewSource).toContain('return InvoiceDistributionOverview;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Distribution overview contract', () => {
|
||||
it('renders decision-first shell sections and executive scanning order', () => {
|
||||
expect(distributionOverviewSource).toContain('class="overview-header hero-surface"');
|
||||
expect(distributionOverviewSource).toContain('class="control-toolbar');
|
||||
expect(distributionOverviewSource).toContain('class="kpi-strip"');
|
||||
expect(distributionOverviewSource).toContain('class="chart-section"');
|
||||
expect(distributionOverviewSource).toContain('distribution-table');
|
||||
expect(distributionOverviewSource).toContain('class="insights-grid"');
|
||||
});
|
||||
|
||||
it('supports month/total sorting and source composition cues', () => {
|
||||
expect(distributionOverviewSource).toContain("const sortKey = ref('month')");
|
||||
expect(distributionOverviewSource).toContain("const sortDirection = ref('desc')");
|
||||
expect(distributionOverviewSource).toContain("setSort('month')");
|
||||
expect(distributionOverviewSource).toContain("setSort('total')");
|
||||
expect(distributionOverviewSource).toContain("sortMonthSummaries(filteredMonthSummaries.value");
|
||||
expect(distributionOverviewSource).toContain('source-composition__segment--fixed');
|
||||
expect(distributionOverviewSource).toContain('source-composition__segment--subscription');
|
||||
expect(distributionOverviewSource).toContain('source-composition__segment--customer-prices');
|
||||
expect(distributionOverviewSource).toContain("metrics.customer_prices");
|
||||
});
|
||||
|
||||
it('keeps loading, error, and empty states with recovery actions', () => {
|
||||
expect(distributionOverviewSource).toContain('InvoiceDistributionSkeleton');
|
||||
expect(distributionOverviewSource).toContain('superuser_invoice_distribution.errors.overview_load_failed');
|
||||
expect(distributionOverviewSource).toContain('superuser_invoice_distribution.actions.try_again');
|
||||
expect(distributionOverviewSource).toContain('superuser_invoice_distribution.empty.no_months');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Distribution monthly view contract', () => {
|
||||
it('uses route params for tab selection and month navigation', () => {
|
||||
expect(distributionMonthSource).toContain("const TABS = ['overview', 'departments', 'customers', 'compare']");
|
||||
expect(distributionMonthSource).toContain('/superuser/invoices/distribution/${year}/${month}/${tab}');
|
||||
expect(distributionMonthSource).toContain('() => route.params.tab');
|
||||
expect(distributionMonthSource).toContain('watch(activeTab, async (tab) => {');
|
||||
});
|
||||
|
||||
it('hydrates and syncs high-value controls through URL query state', () => {
|
||||
expect(distributionMonthSource).toContain("const QUERY_KEYS = [");
|
||||
expect(distributionMonthSource).toContain("'compareMonth'");
|
||||
expect(distributionMonthSource).toContain("'compareMode'");
|
||||
expect(distributionMonthSource).toContain("'compareVisibility'");
|
||||
expect(distributionMonthSource).toContain("'customerSearch'");
|
||||
expect(distributionMonthSource).toContain("'customerSource'");
|
||||
expect(distributionMonthSource).toContain("'customerDepartment'");
|
||||
expect(distributionMonthSource).toContain("'departmentSearch'");
|
||||
expect(distributionMonthSource).toContain('normalizeDistributionQueryState(route.query)');
|
||||
expect(distributionMonthSource).toContain('buildDistributionQueryState({');
|
||||
expect(distributionMonthSource).toContain('router.replace({');
|
||||
});
|
||||
|
||||
it('keeps workflow toolbar priority and summary ribbon', () => {
|
||||
expect(distributionMonthSource).toContain('class="v3-toolbar-card');
|
||||
expect(distributionMonthSource).toContain('class="v3-hero-card');
|
||||
expect(distributionMonthSource).toContain('distribution-month-prev');
|
||||
expect(distributionMonthSource).toContain('distribution-month-next');
|
||||
expect(distributionMonthSource).toContain('distribution-compare-month-picker');
|
||||
expect(distributionMonthSource).toContain('distribution-run-compare');
|
||||
expect(distributionMonthSource).toContain('class="summary-ribbon');
|
||||
});
|
||||
|
||||
it('renders upgraded tab content for overview/departments/customers/compare', () => {
|
||||
expect(distributionMonthSource).toContain("value=\"overview\"");
|
||||
expect(distributionMonthSource).toContain("value=\"departments\"");
|
||||
expect(distributionMonthSource).toContain("value=\"customers\"");
|
||||
expect(distributionMonthSource).toContain("value=\"compare\"");
|
||||
expect(distributionMonthSource).toContain('class="v3-overview-grid');
|
||||
expect(distributionMonthSource).toContain('class="v3-surface');
|
||||
expect(distributionMonthSource).toContain('allocation-chips');
|
||||
expect(distributionMonthSource).toContain('sortCompareRows(compareRows.value)');
|
||||
expect(distributionMonthSource).toContain('selectedWarningInvoiceId');
|
||||
expect(distributionMonthSource).toContain('warning-panel__header');
|
||||
expect(distributionMonthSource).toContain("value=\"customer_prices\"");
|
||||
expect(distributionMonthSource).toContain("distribution-compare-visibility-picker");
|
||||
expect(distributionMonthSource).toContain('monthFallback.usedLegacyDistribution');
|
||||
expect(distributionMonthSource).toContain('monthFallback.usedSplitV2Distribution');
|
||||
expect(distributionMonthSource).toContain('compareFallback.usedLegacyCompare');
|
||||
expect(distributionMonthSource).toContain('compareFallback.usedSingleCompareFallback');
|
||||
});
|
||||
|
||||
it('keeps compare mode values and progress visibility', () => {
|
||||
expect(distributionMonthSource).toContain("const compareMode = ref('invoice_total')");
|
||||
expect(distributionMonthSource).toContain("DISTRIBUTION_COMPARE_MODES");
|
||||
expect(distributionMonthSource).toContain("line_by_line");
|
||||
expect(distributionMonthSource).toContain('compareProgress');
|
||||
expect(distributionMonthSource).toContain('compareCompletionPercent');
|
||||
expect(distributionMonthSource).toContain('onRows:');
|
||||
expect(distributionMonthSource).toContain('aggregateCompareRows(streamedRows)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Distribution accessibility contract', () => {
|
||||
it('adds aria labels, table captions, and focus-visible styles', () => {
|
||||
expect(distributionOverviewSource).toContain('aria-label');
|
||||
expect(distributionOverviewSource).toContain('<caption>');
|
||||
expect(distributionOverviewSource).toContain('focus-visible');
|
||||
expect(distributionMonthSource).toContain('aria-label');
|
||||
expect(distributionMonthSource).toContain('<caption>');
|
||||
expect(distributionMonthSource).toContain('focus-visible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Distribution API adapter contract', () => {
|
||||
it('uses period endpoint and supports v2 all/split distribution with legacy fallback', () => {
|
||||
expect(distributionApiSource).toContain("'/superuser/invoicing/period'");
|
||||
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/v2/all'");
|
||||
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/v2/fixed-pricing'");
|
||||
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/v2/wash-subscriptions'");
|
||||
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/v2/customer-prices'");
|
||||
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/fixed-pricing'");
|
||||
expect(distributionApiSource).toContain("'/superuser/invoicing/period/distribution/wash-subscriptions'");
|
||||
expect(distributionApiSource).toContain('usedLegacyDistribution');
|
||||
expect(distributionApiSource).toContain('usedSplitV2Distribution');
|
||||
expect(distributionApiSource).toContain('dateFrom');
|
||||
expect(distributionApiSource).toContain('dateTo');
|
||||
});
|
||||
|
||||
it('runs month compare against v2 bulk endpoint with single-v2 and legacy fallback paths', () => {
|
||||
expect(distributionApiSource).toContain('compareCollectedInvoicesForMonth');
|
||||
expect(distributionApiSource).toContain("'/collected-invoices/economic/v2/compare/bulk'");
|
||||
expect(distributionApiSource).toContain("'/collected-invoices/economic/v2/compare'");
|
||||
expect(distributionApiSource).toContain("'/collected-invoices/economic/compare'");
|
||||
expect(distributionApiSource).toContain('usedLegacyCompare');
|
||||
expect(distributionApiSource).toContain('usedSingleCompareFallback');
|
||||
expect(distributionApiSource).toContain('normalizeCompareResult');
|
||||
expect(distributionApiSource).toContain('aggregateCompareRows');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Distribution visual components contract', () => {
|
||||
it('uses chart.js line chart with four datasets and aria label support', () => {
|
||||
expect(distributionChartSource).toContain('import { Line } from \'vue-chartjs\'');
|
||||
expect(distributionChartSource).toContain('label: props.labels.total');
|
||||
expect(distributionChartSource).toContain('label: props.labels.booked');
|
||||
expect(distributionChartSource).toContain('label: props.labels.distribution');
|
||||
expect(distributionChartSource).toContain('label: props.labels.customerPrices');
|
||||
expect(distributionChartSource).toContain('ariaLabel');
|
||||
});
|
||||
|
||||
it('provides reusable skeleton shimmer component', () => {
|
||||
expect(distributionSkeletonSource).toContain('loading-shimmer');
|
||||
expect(distributionSkeletonSource).toContain('v-for="index in rows"');
|
||||
expect(distributionSkeletonSource).toContain('skeleton-block');
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,14 @@ describe("superuser invoices route wiring", () => {
|
||||
expect(routerSource).toContain("component: CollectedOrderInvoices");
|
||||
});
|
||||
|
||||
it("maps monthly distribution routes to InvoiceDistributionMonthView", () => {
|
||||
expect(routerSource).toContain("name: 'collectedorderinvoicesdistribution'");
|
||||
expect(routerSource).toContain("path: '/superuser/invoices/distribution/:year/:month'");
|
||||
expect(routerSource).toContain("name: 'collectedorderinvoicesdistributiontab'");
|
||||
expect(routerSource).toContain("path: '/superuser/invoices/distribution/:year/:month/:tab'");
|
||||
expect(routerSource).toContain("component: InvoiceDistributionMonthView");
|
||||
});
|
||||
|
||||
it("keeps details route for a single collected invoice", () => {
|
||||
expect(routerSource).toContain("name: 'collectedorderinvoice'");
|
||||
expect(routerSource).toContain("path: '/superuser/invoices/:collectedOrderInvoiceId'");
|
||||
@@ -120,6 +128,7 @@ describe("CollectedOrderInvoices tab container contract", () => {
|
||||
expect(invoicesViewSource).toContain("{ name: 'Overblik', slot: 'overview'");
|
||||
expect(invoicesViewSource).toContain("{ name: 'Fakturaer', slot: 'invoices'");
|
||||
expect(invoicesViewSource).toContain("{ name: 'Periode', slot: 'period'");
|
||||
expect(invoicesViewSource).toContain("slot: 'distribution'");
|
||||
});
|
||||
|
||||
it("keeps active tab synced with route query", () => {
|
||||
@@ -141,6 +150,8 @@ describe("CollectedOrderInvoices tab container contract", () => {
|
||||
expect(invoicesViewSource).toContain("return CollectedOrderInvoicesListPagination;");
|
||||
expect(invoicesViewSource).toContain("case 'period':");
|
||||
expect(invoicesViewSource).toContain("return InvoicingBillingPeriod;");
|
||||
expect(invoicesViewSource).toContain("case 'distribution':");
|
||||
expect(invoicesViewSource).toContain("return InvoiceDistributionOverview;");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,66 @@
|
||||
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('const relatedTargets = resolveCardRelatedTargets(');
|
||||
expect(modalSource).toContain('relatedTargets,');
|
||||
expect(modalSource).toContain('resolveSyntheticNavigationTarget');
|
||||
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(modalSource).toContain('related-actions-panel');
|
||||
expect(modalSource).toContain('v-if="hasRelatedActions(card)"');
|
||||
expect(modalSource).toContain("@click=\"openRelatedTarget(card, 'order')\"");
|
||||
expect(modalSource).toContain("@click=\"openRelatedTarget(card, 'department')\"");
|
||||
expect(modalSource).toContain("@click=\"openRelatedTarget(card, 'vehicle')\"");
|
||||
expect(modalSource).toContain('@click="copyCardIdentifier(card)"');
|
||||
expect(modalSource).toContain('@click="copyCardReference(card)"');
|
||||
expect(modalSource).toContain('@click="copyCardRegistration(card)"');
|
||||
expect(supportSource).toContain('export const normalizePayload');
|
||||
});
|
||||
|
||||
it('registers a global double-shift shortcut that opens the search modal', () => {
|
||||
expect(modalSource).toContain('const DOUBLE_SHIFT_MAX_INTERVAL_MS = 420');
|
||||
expect(modalSource).toContain('const onGlobalKeyDown = (event: KeyboardEvent) =>');
|
||||
expect(modalSource).toContain('const onGlobalKeyUp = (event: KeyboardEvent) =>');
|
||||
expect(modalSource).toContain("if (event.key === 'Shift')");
|
||||
expect(modalSource).toContain('openSearchFromShortcut();');
|
||||
expect(modalSource).toContain("window.addEventListener('keydown', onGlobalKeyDown)");
|
||||
expect(modalSource).toContain("window.addEventListener('keyup', onGlobalKeyUp)");
|
||||
expect(modalSource).toContain("window.removeEventListener('keydown', onGlobalKeyDown)");
|
||||
expect(modalSource).toContain("window.removeEventListener('keyup', onGlobalKeyUp)");
|
||||
});
|
||||
|
||||
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,428 @@
|
||||
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 restrictedContext = {
|
||||
canAccessSuperUser: false,
|
||||
canAccessAdmin: false,
|
||||
canAccessUser: false,
|
||||
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 59 known entity types', () => {
|
||||
expect(SYSTEM_SEARCH_ENTITY_TYPES).toHaveLength(59);
|
||||
expect(new Set(SYSTEM_SEARCH_ENTITY_TYPES).size).toBe(59);
|
||||
|
||||
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('supports relation-target payload identifiers for orders, departments, and vehicles', () => {
|
||||
const orderFromRelation = resolveSystemSearchNavigationTarget(
|
||||
row('orders', {
|
||||
entity_id: '34455',
|
||||
payload: { order_id: '34455', department_id: 4 },
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
const departmentFromRelation = resolveSystemSearchNavigationTarget(
|
||||
row('departments', {
|
||||
entity_id: '4',
|
||||
payload: { department_id: 4 },
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
const vehicleFromRelation = resolveSystemSearchNavigationTarget(
|
||||
row('vehicles', {
|
||||
entity_id: 'AB12345',
|
||||
payload: { reg: 'AB12345' },
|
||||
}),
|
||||
superuserContext
|
||||
);
|
||||
|
||||
expect(orderFromRelation.to.path).toBe('/admin/4/modules/pos/orders/34455');
|
||||
expect(departmentFromRelation.to.path).toBe('/admin/4');
|
||||
expect(vehicleFromRelation.to.path).toBe('/superuser/vehicles/AB12345');
|
||||
});
|
||||
|
||||
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('delegates object item aliases to order-item routing behavior', () => {
|
||||
const nav = resolveSystemSearchNavigationTarget(
|
||||
row('objects', {
|
||||
entity_id: '998',
|
||||
payload: {
|
||||
object_type: 'item',
|
||||
order_id: '34455',
|
||||
department: '4',
|
||||
},
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
expect(nav.strategy).toBe('deep-link');
|
||||
expect(nav.to.path).toBe('/admin/4/modules/pos/orders/34455');
|
||||
});
|
||||
|
||||
it('routes object discount/fixed-price aliases to user pricing routes', () => {
|
||||
const withUser = resolveSystemSearchNavigationTarget(
|
||||
row('objects', {
|
||||
payload: {
|
||||
object_type: 'discount',
|
||||
user_id: '91',
|
||||
},
|
||||
}),
|
||||
superuserContext
|
||||
);
|
||||
const noUser = resolveSystemSearchNavigationTarget(
|
||||
row('objects', {
|
||||
payload: {
|
||||
object_type: 'fixed_pricing',
|
||||
},
|
||||
}),
|
||||
superuserContext
|
||||
);
|
||||
|
||||
expect(withUser.to.path).toBe('/superuser/users/91/pricing');
|
||||
expect(noUser.to.path).toBe('/superuser/users');
|
||||
});
|
||||
|
||||
it('routes noisy attachment-like object aliases to the linked order page', () => {
|
||||
const nav = resolveSystemSearchNavigationTarget(
|
||||
row('objects', {
|
||||
payload: {
|
||||
object_type: '`DOCUMENT`',
|
||||
order_id: '34455',
|
||||
department_id: 4,
|
||||
},
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
expect(nav.strategy).toBe('deep-link');
|
||||
expect(nav.to.path).toBe('/admin/4/modules/pos/orders/34455');
|
||||
});
|
||||
|
||||
it('routes object question/task/condition aliases to self-serve module pages', () => {
|
||||
const questionNav = resolveSystemSearchNavigationTarget(
|
||||
row('objects', {
|
||||
payload: { object_type: 'question', department_id: 4 },
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
const taskNav = resolveSystemSearchNavigationTarget(
|
||||
row('objects', {
|
||||
payload: { object_type: 'task', department_id: 4 },
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
const conditionNav = resolveSystemSearchNavigationTarget(
|
||||
row('objects', {
|
||||
payload: { object_type: 'condition', department_id: 4 },
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
|
||||
expect(questionNav.to.path).toBe('/admin/4/modules/self-serve/questions');
|
||||
expect(taskNav.to.path).toBe('/admin/4/modules/self-serve/tasks');
|
||||
expect(conditionNav.to.path).toBe('/admin/4/modules/self-serve/conditions');
|
||||
});
|
||||
|
||||
it('keeps generic fallback when object subtype lacks required routing context', () => {
|
||||
const nav = resolveSystemSearchNavigationTarget(
|
||||
row('objects', {
|
||||
payload: { object_type: 'discount' },
|
||||
}),
|
||||
restrictedContext
|
||||
);
|
||||
expect(nav.strategy).toBe('generic');
|
||||
expect(nav.to.path).toContain('/search/system/record/customer_discounts/11');
|
||||
});
|
||||
|
||||
it('routes department variables by role and department context', () => {
|
||||
const adminNav = resolveSystemSearchNavigationTarget(
|
||||
row('department_variables', {
|
||||
payload: { department_id: 4 },
|
||||
}),
|
||||
adminContext
|
||||
);
|
||||
const superNav = resolveSystemSearchNavigationTarget(
|
||||
row('department_variables', {
|
||||
payload: { department_id: 4 },
|
||||
}),
|
||||
superuserContext
|
||||
);
|
||||
|
||||
expect(adminNav.to.path).toBe('/admin/4');
|
||||
expect(superNav.to.path).toBe('/superuser/departments/4');
|
||||
});
|
||||
|
||||
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(itemsField.value).toContain('(complete)');
|
||||
expect(itemsField.value).toContain('Indvendig vask Trailer');
|
||||
expect(itemsField.value).toContain('Safety Seal');
|
||||
expect(vm.parsedPayload.items).toBeTypeOf('object');
|
||||
});
|
||||
|
||||
it('picks up nested booking items and reports missing details when content is incomplete', () => {
|
||||
const vm = buildSystemSearchViewModel(row('order_bookings', {
|
||||
payload: {
|
||||
content: {
|
||||
items: [
|
||||
{ id: 10, name: 'Bus' },
|
||||
{ id: 41, name: 'Spot Free- Lastbil' },
|
||||
{ id: 91 }
|
||||
]
|
||||
},
|
||||
datetime: '2025-11-24 23:00:00',
|
||||
reg_1: 'FC2861',
|
||||
},
|
||||
}));
|
||||
|
||||
const itemsField = vm.keyFields.find((entry) => entry.label === 'Items');
|
||||
expect(itemsField.value).toContain('3 items');
|
||||
expect(itemsField.value).toContain('missing details: 1');
|
||||
expect(itemsField.value).toContain('Bus');
|
||||
expect(itemsField.value).toContain('Spot Free- Lastbil');
|
||||
expect(itemsField.value).toContain('#91');
|
||||
});
|
||||
|
||||
it('shows customer discount value and timestamps in key fields', () => {
|
||||
const vm = buildSystemSearchViewModel(row('customer_discounts', {
|
||||
entity_id: '326',
|
||||
title: 'Discount #326',
|
||||
payload: {
|
||||
id: 326,
|
||||
customer_number: 12345679,
|
||||
discount: 12.5,
|
||||
object_id: 'global',
|
||||
is_category: true,
|
||||
created_at: '2026-03-12T09:15:00Z',
|
||||
},
|
||||
}));
|
||||
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Discount' && entry.value === '12.5%')).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Target' && entry.value === 'global')).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Category' && entry.value === 'Yes')).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Created' && entry.value.includes('2026-03-12'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reuses customer-discount render fields for objects discount aliases', () => {
|
||||
const vm = buildSystemSearchViewModel(row('objects', {
|
||||
entity_id: '326',
|
||||
title: '326',
|
||||
payload: {
|
||||
object_type: 'discount',
|
||||
customer_number: 12345679,
|
||||
percentage: 8,
|
||||
product_or_category_id: '42',
|
||||
is_category: false,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Discount' && entry.value === '8%')).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Target' && entry.value === '42')).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Category' && entry.value === 'No')).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces nested goal criteria and progress details for department goals', () => {
|
||||
const vm = buildSystemSearchViewModel(row('department_goals', {
|
||||
entity_id: '1',
|
||||
title: '1',
|
||||
payload: {
|
||||
id: 1,
|
||||
criteria: {
|
||||
label: 'Monthly Revenue Goal',
|
||||
type: 'REVENUE',
|
||||
start: '2026-03-01',
|
||||
end: '2026-03-31',
|
||||
target: 100000
|
||||
},
|
||||
progress: {
|
||||
to_date: {
|
||||
count: 42000,
|
||||
target: 100000
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
expect(vm.title).toBe('Monthly Revenue Goal');
|
||||
expect(vm.description).toBe('REVENUE');
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Start' && entry.value.includes('2026-03-01'))).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'End' && entry.value.includes('2026-03-31'))).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Target' && entry.value.includes('100000'))).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Progress' && entry.value.includes('42%'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reuses department-goal render fields for objects goal aliases', () => {
|
||||
const vm = buildSystemSearchViewModel(row('objects', {
|
||||
entity_id: '1',
|
||||
title: '1',
|
||||
payload: {
|
||||
object_type: '`goal`',
|
||||
criteria: {
|
||||
label: 'Weekly Throughput Goal',
|
||||
type: 'ORDERS',
|
||||
start: '2026-03-10',
|
||||
end: '2026-03-17',
|
||||
target: 50
|
||||
},
|
||||
progress: {
|
||||
this_week: {
|
||||
count: 21,
|
||||
target: 50
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
expect(vm.title).toBe('Weekly Throughput Goal');
|
||||
expect(vm.description).toBe('ORDERS');
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Start' && entry.value.includes('2026-03-10'))).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'End' && entry.value.includes('2026-03-17'))).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Target' && entry.value.includes('50'))).toBe(true);
|
||||
expect(vm.keyFields.some((entry) => entry.label === 'Progress' && entry.value.includes('42%'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user