Add unit test for user vehicle actions visibility and enhancements for department 75 loading UI, default department handling, and translations.

This commit is contained in:
Jeppe Bundgaard
2026-05-13 15:35:21 +02:00
parent f7f01dcd2f
commit 8dfaebba00
10 changed files with 608 additions and 304 deletions
@@ -240,6 +240,7 @@ const getProductOptionsLabel = (vehicle) => {
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="true"
>
<template #actions>
<!-- View (Redirect to the vehicle page) -->
@@ -257,6 +257,9 @@ export const getSessionData = async () => {
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = normalizePositiveInteger(
response?.data?.data?.runtime_config?.economic?.transaction_draft_customer_number
);
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = normalizePositiveInteger(
response?.data?.data?.runtime_config?.economic?.default_distribution_department_id
);
// E-conomic data is only fetched if the array isn't empty
if (response.data.data.economic_customer.length > 0) {
SessionUser.economicData.customerNumber.value = response.data.data.economic_customer.customerNumber;
@@ -428,6 +431,7 @@ export const SessionUser = {
runtimeConfig: {
economic: {
transactionDraftCustomerNumber: ref(null),
defaultDistributionDepartmentId: ref(null),
},
},
virtualPermissions: computed(() => {
@@ -667,6 +671,7 @@ export const SessionUser = {
SessionUser.economicData.country.value = null;
SessionUser.economicData.cached_at.value = null;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
getSessionData();
},
auth: {
@@ -68,6 +68,14 @@ export const Config = {
set: async (customerNumber) => {
return Config.set("transactionDraftCustomerNumber", customerNumber);
},
},
defaultDepartmentId: {
get: async () => {
return Config.get("defaultDepartmentId");
},
set: async (departmentId) => {
return Config.set("defaultDepartmentId", departmentId);
},
}
};
+289 -285
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1183,6 +1183,8 @@
"invoice_settings_desc": "E-conomic-konfigurasjonen lar deg konfigurere integrasjonen med E-conomic-regnskapssystemet.",
"invoice_template": "Fakturamal",
"invoice_template_desc": "Velg fakturamal for E-conomic-integrasjonen.",
"default_distribution_department_id": "Standard fallback-avdeling",
"default_distribution_department_id_desc": "Brukes til fordeling av vaskeavtaler og fastpris når kunden mangler standardavdeling.",
"show_payment_terms": "Vis betalingsbetingelser",
"transaction_draft_customer_number": "Kundenummer for transaksjonsutkast",
"transaction_draft_customer_number_desc": "Angi kundenummeret som markerer ordre og fakturasamlinger som utkast. La feltet stå tomt for å deaktivere funksjonen.",
+2
View File
@@ -1183,6 +1183,8 @@
"invoice_settings_desc": "E-conomic-konfigurationen låter dig konfigurera integrationen med E-conomic bokföringssystem.",
"invoice_template": "Faktura skabelon",
"invoice_template_desc": "Välj fakturamallen för E-conomic-integrationen.",
"default_distribution_department_id": "Standard fallback-avdelning",
"default_distribution_department_id_desc": "Används för fördelning av tvättavtal och fastpris när kunden saknar standardavdelning.",
"show_payment_terms": "Vis betalingsbetingelser",
"transaction_draft_customer_number": "Kundnummer för transaktionsutkast",
"transaction_draft_customer_number_desc": "Ange kundnumret som markerar order och fakturasamlingar som utkast. Lämna tomt för att inaktivera funktionen.",
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref, watch } from "vue";
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({
department_ids: {
@@ -21,6 +22,9 @@ const state = ref([]);
const latestRequestToken = ref(0);
const isLoading = ref(false);
const summarizeDaily = ref(false);
const hourDetailsBySlot = ref({});
const latestHourDetailsRequestTokenBySlot = ref({});
const isSuperUser = computed(() => SessionUser.canAccessSuperUser());
const statusPriority = {
unknown: 0,
healthy: 1,
@@ -168,6 +172,134 @@ const getEntryTestId = (prefix, entry) => {
return `department-weather-${prefix}-${date}-${time}`;
};
const getHourDetailsSlotKey = (entry) => {
if (!isDateString(entry?.date)) {
return null;
}
const timeValue = typeof entry?.time === "string" ? entry.time : "";
const normalizedTime = timeValue.slice(0, 5);
if (!/^\d{2}:\d{2}$/.test(normalizedTime)) {
return null;
}
return `${entry.date} ${normalizedTime}`;
};
const canShowHourDetailsTooltip = (entry) => {
if (!isSuperUser.value) {
return false;
}
if (summarizeDaily.value && isMultiDaySelection.value) {
return false;
}
return getHourDetailsSlotKey(entry) !== null;
};
const getHourDetailsState = (entry) => {
const key = getHourDetailsSlotKey(entry);
if (!key) {
return {
status: "idle",
data: null,
};
}
return hourDetailsBySlot.value[key] || {
status: "idle",
data: null,
};
};
const formatHoursTooltipValue = (value) => {
const numeric = toNumberOrNaN(value);
if (Number.isNaN(numeric)) {
return "0";
}
if (Number.isInteger(numeric)) {
return String(numeric);
}
return numeric.toFixed(2).replace(/\.00$/, "").replace(/(\.\d)0$/, "$1");
};
const loadHourDetails = async (entry) => {
if (!canShowHourDetailsTooltip(entry)) {
return;
}
const slotKey = getHourDetailsSlotKey(entry);
if (!slotKey) {
return;
}
const currentState = hourDetailsBySlot.value[slotKey];
if (currentState?.status === "loading" || currentState?.status === "loaded") {
return;
}
const ids = normalizeDepartmentIds(props.department_ids);
if (ids.length === 0) {
return;
}
const requestToken = (latestHourDetailsRequestTokenBySlot.value[slotKey] || 0) + 1;
latestHourDetailsRequestTokenBySlot.value = {
...latestHourDetailsRequestTokenBySlot.value,
[slotKey]: requestToken,
};
hourDetailsBySlot.value = {
...hourDetailsBySlot.value,
[slotKey]: {
status: "loading",
data: null,
},
};
try {
const response = await ObjectsGlobal.get.objects("/departments/weather/hours/details", {
ids: ids.join(","),
date: entry.date,
time: String(entry.time || "").slice(0, 5),
});
if ((latestHourDetailsRequestTokenBySlot.value[slotKey] || 0) !== requestToken) {
return;
}
const departments = Array.isArray(response?.departments) ? response.departments : [];
hourDetailsBySlot.value = {
...hourDetailsBySlot.value,
[slotKey]: {
status: "loaded",
data: {
departments,
},
},
};
} catch (e) {
if ((latestHourDetailsRequestTokenBySlot.value[slotKey] || 0) !== requestToken) {
return;
}
hourDetailsBySlot.value = {
...hourDetailsBySlot.value,
[slotKey]: {
status: "error",
data: null,
},
};
}
};
const onHoursCellHover = (entry) => {
loadHourDetails(entry);
};
const formatHoursDisplay = (value) => {
const numeric = toNumberOrNaN(value);
if (Number.isNaN(numeric)) {
@@ -238,6 +370,8 @@ const getWeather = async () => {
watch(
() => [props.department_ids, props.date_from, props.date_to],
() => {
hourDetailsBySlot.value = {};
latestHourDetailsRequestTokenBySlot.value = {};
getWeather();
},
{
@@ -293,7 +427,56 @@ watch(
v-for="(item, index) in displayedState"
:key="`hours-${getEntryKey(item, index)}`"
:data-testid="getEntryTestId('hours', item)"
>{{ formatHoursDisplay(item.hours) }}</td>
>
<b-tooltip
v-if="canShowHourDetailsTooltip(item)"
append-to-body
multilined
type="is-light"
position="is-top"
class="department-weather-hours-tooltip"
>
<template #content>
<div class="department-weather-hours-tooltip-content">
<div v-if="getHourDetailsState(item).status === 'loading'" class="department-weather-hours-tooltip-message">
Loading employee details...
</div>
<div v-else-if="getHourDetailsState(item).status === 'error'" class="department-weather-hours-tooltip-message">
Unable to load employee details.
</div>
<div v-else-if="!getHourDetailsState(item).data?.departments?.length" class="department-weather-hours-tooltip-message">
No employee hours found.
</div>
<div v-else>
<div
v-for="department in getHourDetailsState(item).data.departments"
:key="`department-hours-${department.department_id}`"
class="department-weather-hours-tooltip-department"
>
<div class="department-weather-hours-tooltip-department-header">
<strong>{{ department.department_name }}</strong>
<span>{{ formatHoursTooltipValue(department.hours) }}</span>
</div>
<div
v-for="employee in department.employees"
:key="`employee-hours-${department.department_id}-${employee.employee_id || employee.employee_name}`"
class="department-weather-hours-tooltip-employee"
>
<span>{{ employee.employee_name }}</span>
<span>{{ formatHoursTooltipValue(employee.hours) }}</span>
</div>
</div>
</div>
</div>
</template>
<template #default>
<span class="department-weather-hours-tooltip-trigger" @mouseenter="onHoursCellHover(item)">
{{ formatHoursDisplay(item.hours) }}
</span>
</template>
</b-tooltip>
<span v-else>{{ formatHoursDisplay(item.hours) }}</span>
</td>
</tr>
<tr>
@@ -394,6 +577,50 @@ watch(
font-weight: 600;
}
.daily-metrics .department-weather-hours-tooltip-trigger {
display: inline-flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
cursor: help;
}
:deep(.department-weather-hours-tooltip-content) {
min-width: 260px;
max-width: 360px;
font-size: 12px;
color: #2b2b2b;
}
:deep(.department-weather-hours-tooltip-message) {
font-weight: 600;
}
:deep(.department-weather-hours-tooltip-department + .department-weather-hours-tooltip-department) {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e4e4e4;
}
:deep(.department-weather-hours-tooltip-department-header),
:deep(.department-weather-hours-tooltip-employee) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
:deep(.department-weather-hours-tooltip-department-header) {
margin-bottom: 4px;
font-weight: 600;
}
:deep(.department-weather-hours-tooltip-employee) {
font-size: 11px;
color: #3f3f3f;
}
.daily-metrics .clear,
.daily-metrics .mostly_clear,
.daily-metrics .partly_cloudy,
@@ -602,46 +602,53 @@ const formatCurrencyAmountOrDash = (amount: any) => {
<div class="mt-0 has-text-right">
<!-- If any of the department 75 data is not fully loaded, show the loading status buttons -->
<template v-if="!isDepartment75CombinedFullyLoaded">
<div class="columns is-mobile">
<div class="columns is-mobile is-multiline is-gapless mb-0" data-testid="department-75-combined-loading-status">
<!-- Loading state: 1. Fixed pricing distribution loading -->
<div class="column is-one-third">
<button class="button is-dark is-small is-fullwidth is-justify-content-space-between has-text-white" :class="{'is-active': fixed_pricing_department_distribution?.total_fixed_price === undefined}">
<div class="column" v-if="!fixed_pricing_department_distribution || fixed_pricing_department_distribution?.total_fixed_price === undefined">
<button class="button is-text is-small is-fullwidth no-underline" :class="{'is-active': fixed_pricing_department_distribution?.total_fixed_price === undefined}">
<!-- [Label] [Icon pulled right] -->
<span>Fast-pris fordeling</span>
<span class="icon is-small">
<i class="fas" :class="fixed_pricing_department_distribution?.total_fixed_price === undefined ? 'fa-spinner fa-spin' : 'fa-check'"></i>
<i class="fas" :class="fixed_pricing_department_distribution?.total_fixed_price === undefined ? 'fa-spinner fa-spin' : 'fa-check-circle'"></i>
</span>
<span>Fast-pris aftaler</span>
</button>
</div>
<!-- Loading state: 2. Vehicle subscription distribution loading -->
<div class="column is-one-third">
<button class="button is-dark is-small is-fullwidth is-justify-content-space-between has-text-white" :class="{'is-active': vehicleSubscriptionDistribution?.total_subscription_price === undefined}">
<div class="column" v-if="!vehicleSubscriptionDistribution || vehicleSubscriptionDistribution?.total_subscription_price === undefined">
<button class="button is-text is-small is-fullwidth no-underline" :class="{'is-active': vehicleSubscriptionDistribution?.total_subscription_price === undefined}">
<!-- [Label] [Icon pulled right] -->
<span>Vaske-aftale fordeling</span>
<span class="icon is-small">
<i class="fas" :class="vehicleSubscriptionDistribution?.total_subscription_price === undefined ? 'fa-spinner fa-spin' : 'fa-check'"></i>
<i class="fas" :class="vehicleSubscriptionDistribution?.total_subscription_price === undefined ? 'fa-spinner fa-spin' : 'fa-check-circle'"></i>
</span>
<span>Vaskeabonnementer</span>
</button>
</div>
<!-- Loading state: 3. Booked department 75 distribution loading -->
<div class="column is-one-third">
<button class="button is-dark is-small is-fullwidth is-justify-content-space-between has-text-white" :class="{'is-active': !bookedDepartment75DistributionLoaded || bookedDepartment75DistributionFailed}">
<div class="column" v-if="!bookedDepartment75DistributionLoaded || bookedDepartment75DistributionFailed">
<button class="button is-text is-small is-fullwidth no-underline" :class="{'is-active': !bookedDepartment75DistributionLoaded || bookedDepartment75DistributionFailed}">
<!-- [Label] [Icon pulled right] -->
<span>Afdeling 75 bogført fordeling</span>
<span class="icon is-small">
<i class="fas" :class="!bookedDepartment75DistributionLoaded || bookedDepartment75DistributionFailed ? 'fa-spinner fa-spin' : 'fa-check'"></i>
<i class="fas" :class="!bookedDepartment75DistributionLoaded ? 'fa-spinner fa-spin' : (bookedDepartment75DistributionFailed ? 'fa-exclamation-triangle' : 'fa-check-circle')"></i>
</span>
<span>Afdeling 75 (bogført)</span>
</button>
</div>
<!-- Loading state: 4. Overall combined data loading (based on the three above) -->
<div class="column" v-if="!isDepartment75CombinedFullyLoaded">
<button class="button is-text is-small is-fullwidth no-underline" :class="{'is-active': !isDepartment75CombinedFullyLoaded}">
<!-- [Label] [Icon pulled right] -->
<span class="icon is-small">
<i class="fas" :class="!isDepartment75CombinedFullyLoaded ? 'fa-spinner fa-spin' : 'fa-check-circle'"></i>
</span>
<span>Afdeling 75 (samlet)</span>
</button>
</div>
</div>
</template>
<button
class="button is-dark is-small is-fullwidth"
:class="{'is-loading': !isDepartment75CombinedFullyLoaded}"
class="button is-text is-small is-fullwidth no-underline"
@click="downloadExcel"
v-else
v-if="isDepartment75CombinedFullyLoaded"
>
<span class="icon is-small">
<i class="fas fa-file-excel"></i>
@@ -884,4 +891,7 @@ const formatCurrencyAmountOrDash = (amount: any) => {
font-size: 0.78rem;
}
}
.no-underline {
text-decoration: none;
}
</style>
@@ -22,6 +22,7 @@ const router = useRouter()
const layouts = ref([]);
const payment_terms = ref([]);
const departments = ref([]);
const module_config = ref([]);
const getLayouts = async () => {
@@ -55,6 +56,21 @@ const getPaymentTerms = async () => {
});
};
const getDepartments = async () => {
await SessionUser.objects.departments.get.all().then((departmentResults) => {
departments.value = [];
for (let i = 0; i < departmentResults.length; i++) {
departments.value.push(
ConfigurationSelectOption(
departmentResults[i].name, departmentResults[i].id,
)
);
}
}).catch((error) => {
console.log(error);
});
};
const getModuleConfig = async () => {
await SessionUser.superUser.modules.economic.config.get_all().then((response) => {
let tmp_module_config = response.data.data;
@@ -104,8 +120,18 @@ const saveDraftTransactionCustomerNumber = async (customerNumber) => {
return response;
};
const saveDefaultDepartmentId = async (departmentId) => {
const normalizedDepartmentId = Number.isInteger(Number.parseInt(String(departmentId ?? ''), 10))
&& Number.parseInt(String(departmentId ?? ''), 10) > 0
? Number.parseInt(String(departmentId ?? ''), 10)
: 1;
return SessionUser.superUser.modules.economic.config.defaultDepartmentId.set(normalizedDepartmentId);
};
const load = async () => {
await getLayouts();
await getDepartments();
await getModuleConfig();
};
@@ -170,6 +196,13 @@ load();
:value="getModuleConfigValue('invoiceLayoutNumber')"
:on-select="SessionUser.superUser.modules.economic.config.layouts.invoiceLayoutNumber.set"
/>
<ConfigurationSelect
:label="$t('configuration.economic.default_distribution_department_id')"
:description="$t('configuration.economic.default_distribution_department_id_desc')"
:options="departments"
:value="parseNullableConfigNumber('defaultDepartmentId')"
:on-select="saveDefaultDepartmentId"
/>
<!-- Buttons -->
<button
class="button is-link is-small mt-2"
@@ -0,0 +1,12 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const vehiclesTableSource = readFileSync(join(root, "src/components/displays/user/vehicles/vehiclesTable.vue"), "utf8");
describe("user vehicles actions visibility contract", () => {
it("renders vehicle row actions directly so they are fully visible on the page", () => {
expect(vehiclesTableSource).toContain(':displayActionsDirectly="true"');
});
});