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:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+228
-1
@@ -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,
|
||||
|
||||
+28
-18
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user