This commit is contained in:
gold-dev
2026-03-13 11:03:21 +00:00
34 changed files with 22847 additions and 7813 deletions
+2318 -23
View File
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
View File
File diff suppressed because it is too large Load Diff
+2753 -2475
View File
File diff suppressed because it is too large Load Diff
+1496 -1218
View File
File diff suppressed because it is too large Load Diff
+2448 -2170
View File
File diff suppressed because it is too large Load Diff
+1304 -1026
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -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;
}
@@ -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>
@@ -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>
@@ -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>
@@ -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),
},
};
}
};
@@ -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,
};
};
+308
View File
@@ -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
View File
@@ -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;
+520
View File
@@ -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');
});
});
+74
View File
@@ -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);
});
});