Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da8c01db5 | ||
|
|
3725c2e52c | ||
|
|
502a97fb52 |
@@ -140,6 +140,7 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -152,8 +153,13 @@ jobs:
|
||||
else
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
fi
|
||||
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_HEAD_SHA" ]]; then
|
||||
head_ref="$PR_HEAD_SHA"
|
||||
else
|
||||
head_ref="$HEAD_SHA"
|
||||
fi
|
||||
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
|
||||
@@ -33,6 +33,16 @@ export const sourceMappings = [
|
||||
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "superuser-department-overview",
|
||||
patterns: [
|
||||
/^src\/services\/superuserDepartmentOverview\.js$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u,
|
||||
],
|
||||
specs: ["tests/e2e/superuser-department-overview.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "booking",
|
||||
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
||||
|
||||
@@ -101,6 +101,7 @@ export const ownedFilesByRole = {
|
||||
"superuser-customer-complaints.spec.ts",
|
||||
"superuser-customers-mass-import.spec.ts",
|
||||
"superuser-department-branding.spec.js",
|
||||
"superuser-department-overview.spec.js",
|
||||
"superuser-department-gates.spec.ts",
|
||||
"superuser-department-lanes.spec.ts",
|
||||
"superuser-departments-archive.spec.ts",
|
||||
|
||||
@@ -52,10 +52,6 @@ loadList();
|
||||
:columnLabels="{ type: SessionUser.objects.vehicles.columns.type.label }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="vehicles-pagination__add-action">
|
||||
<label class="label is-small"> </label>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<!-- Create a new vehicle, if the route is /user -->
|
||||
<div class="vehicles-pagination__add-action"
|
||||
v-if="router.currentRoute.value.path.startsWith('/user')">
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import CustomerSearchSelect from "@/components/search/economic/CustomerSearchSelect.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const emit = defineEmits(["close", "created"]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedCustomer = ref(null);
|
||||
const registrationNumber = ref("");
|
||||
const vehicleType = ref("");
|
||||
const washSubscription = ref(false);
|
||||
const reference = ref("");
|
||||
const vehicleTypeOptions = ref([]);
|
||||
const isLoadingVehicleTypes = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const vehicleTypeError = ref("");
|
||||
|
||||
const getCustomerNumber = (customer) => {
|
||||
const parsedValue = Number.parseInt(
|
||||
String(customer?.customerNumber ?? customer?.customer_number ?? customer?.customer_id ?? customer?.id ?? ""),
|
||||
10
|
||||
);
|
||||
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const selectedCustomerNumber = computed(() => getCustomerNumber(selectedCustomer.value));
|
||||
|
||||
const normalizedVehicleType = computed(() => {
|
||||
if (vehicleType.value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(String(vehicleType.value), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : null;
|
||||
});
|
||||
|
||||
const normalizedRegistrationNumber = computed(() => {
|
||||
return registrationNumber.value.trim().toUpperCase();
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return (
|
||||
!isSubmitting.value &&
|
||||
!isLoadingVehicleTypes.value &&
|
||||
selectedCustomerNumber.value !== null &&
|
||||
normalizedRegistrationNumber.value.length > 0 &&
|
||||
normalizedVehicleType.value !== null
|
||||
);
|
||||
});
|
||||
|
||||
const parseErrorMessage = (error) => {
|
||||
return SessionUser.functions.parseErrorMessage(error) || t("vehicles.add_modal.error");
|
||||
};
|
||||
|
||||
const loadVehicleTypes = async () => {
|
||||
isLoadingVehicleTypes.value = true;
|
||||
vehicleTypeError.value = "";
|
||||
|
||||
try {
|
||||
vehicleTypeOptions.value = await SessionUser.objects.vehicles.columns.type.options();
|
||||
} catch (error) {
|
||||
vehicleTypeError.value = parseErrorMessage(error) || t("vehicles.add_modal.type_load_error");
|
||||
} finally {
|
||||
isLoadingVehicleTypes.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (!isSubmitting.value) {
|
||||
emit("close");
|
||||
}
|
||||
};
|
||||
|
||||
const submitVehicle = async () => {
|
||||
if (!canSubmit.value) {
|
||||
errorMessage.value = t("vehicles.add_modal.validation_error");
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await SessionUser.objects.vehicles.add(
|
||||
normalizedVehicleType.value,
|
||||
normalizedRegistrationNumber.value,
|
||||
washSubscription.value,
|
||||
selectedCustomerNumber.value,
|
||||
reference.value.trim() || null
|
||||
);
|
||||
|
||||
emit("created", response);
|
||||
} catch (error) {
|
||||
errorMessage.value = parseErrorMessage(error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadVehicleTypes();
|
||||
await nextTick();
|
||||
document.getElementById("superuser-add-vehicle-customer-search")?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal is-active" data-testid="superuser-add-vehicle-modal">
|
||||
<div class="modal-background" @click="closeModal"></div>
|
||||
<div class="modal-card superuser-add-vehicle-modal">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ t("vehicles.add_modal.title") }}</p>
|
||||
<button
|
||||
class="delete"
|
||||
type="button"
|
||||
:aria-label="t('common.close')"
|
||||
data-testid="superuser-add-vehicle-close"
|
||||
@click="closeModal"
|
||||
></button>
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body">
|
||||
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="superuser-add-vehicle-error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<CustomerSearchSelect
|
||||
v-model="selectedCustomer"
|
||||
input-id="superuser-add-vehicle-customer-search"
|
||||
test-id-prefix="superuser-add-vehicle-customer"
|
||||
:disabled="isSubmitting"
|
||||
:placeholder="t('vehicles.add_modal.customer_placeholder')"
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="superuser-add-vehicle-registration">{{ t("vehicles.form.license_plate") }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="superuser-add-vehicle-registration"
|
||||
v-model="registrationNumber"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('vehicles.add_modal.registration_placeholder')"
|
||||
:disabled="isSubmitting"
|
||||
data-testid="superuser-add-vehicle-registration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="superuser-add-vehicle-type">{{ t("vehicles.form.type") }}</label>
|
||||
<div class="control" :class="{ 'is-loading': isLoadingVehicleTypes }">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
id="superuser-add-vehicle-type"
|
||||
v-model="vehicleType"
|
||||
:disabled="isSubmitting || isLoadingVehicleTypes || vehicleTypeOptions.length === 0"
|
||||
data-testid="superuser-add-vehicle-type"
|
||||
>
|
||||
<option disabled value="">{{ t("vehicles.add_modal.type_placeholder") }}</option>
|
||||
<option v-for="option in vehicleTypeOptions" :key="option.id" :value="option.id">
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="vehicleTypeError" class="help is-danger" data-testid="superuser-add-vehicle-type-error">
|
||||
{{ vehicleTypeError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="washSubscription"
|
||||
type="checkbox"
|
||||
:disabled="isSubmitting"
|
||||
data-testid="superuser-add-vehicle-wash-subscription"
|
||||
/>
|
||||
{{ t("objects.vehicles.columns.wash_subscription") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="superuser-add-vehicle-reference">{{ t("common.reference") }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="superuser-add-vehicle-reference"
|
||||
v-model="reference"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('vehicles.add_modal.reference_placeholder')"
|
||||
:disabled="isSubmitting"
|
||||
data-testid="superuser-add-vehicle-reference"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot is-justify-content-flex-end">
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
:disabled="isSubmitting"
|
||||
data-testid="superuser-add-vehicle-cancel"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ t("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button is-link"
|
||||
type="button"
|
||||
:class="{ 'is-loading': isSubmitting }"
|
||||
:disabled="!canSubmit"
|
||||
data-testid="superuser-add-vehicle-submit"
|
||||
@click="submitVehicle"
|
||||
>
|
||||
{{ t("vehicles.add_modal.submit") }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.superuser-add-vehicle-modal {
|
||||
max-width: min(44rem, calc(100vw - 2rem));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-card-body {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -1,288 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isSearching, searchCustomer, searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inputId: {
|
||||
type: String,
|
||||
default: "customer-search-select-input",
|
||||
},
|
||||
testIdPrefix: {
|
||||
type: String,
|
||||
default: "customer-search-select",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "selected", "cleared"]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchQuery = ref("");
|
||||
const showResults = ref(false);
|
||||
const selectedResultIndex = ref(-1);
|
||||
|
||||
const getCustomerNumber = (customer) => {
|
||||
const parsedValue = Number.parseInt(
|
||||
String(customer?.customerNumber ?? customer?.customer_number ?? customer?.customer_id ?? customer?.id ?? ""),
|
||||
10
|
||||
);
|
||||
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const getCustomerName = (customer) => {
|
||||
return String(customer?.name ?? customer?.customer_name ?? customer?.customerName ?? "").trim();
|
||||
};
|
||||
|
||||
const getCustomerCity = (customer) => {
|
||||
return String(customer?.city ?? customer?.address_city ?? "").trim();
|
||||
};
|
||||
|
||||
const formatCustomer = (customer) => {
|
||||
if (!customer) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const name = getCustomerName(customer);
|
||||
const customerNumber = getCustomerNumber(customer);
|
||||
|
||||
return [name, customerNumber ? `#${customerNumber}` : null].filter(Boolean).join(" - ");
|
||||
};
|
||||
|
||||
const selectedCustomerNumber = computed(() => getCustomerNumber(props.modelValue));
|
||||
const selectedCustomerName = computed(() => getCustomerName(props.modelValue));
|
||||
const selectedCustomerCity = computed(() => getCustomerCity(props.modelValue));
|
||||
const hasResults = computed(() => searchCustomerResults.value.length > 0);
|
||||
const placeholderText = computed(() => props.placeholder || t("vehicles.add_modal.customer_placeholder"));
|
||||
|
||||
const resetSearchResults = () => {
|
||||
searchCustomer(null);
|
||||
selectedResultIndex.value = -1;
|
||||
};
|
||||
|
||||
const setSelectedCustomer = (customer) => {
|
||||
emit("update:modelValue", customer);
|
||||
emit("selected", customer);
|
||||
searchQuery.value = formatCustomer(customer);
|
||||
showResults.value = false;
|
||||
selectedResultIndex.value = -1;
|
||||
};
|
||||
|
||||
const clearSelectedCustomer = async () => {
|
||||
emit("update:modelValue", null);
|
||||
emit("cleared");
|
||||
searchQuery.value = "";
|
||||
showResults.value = false;
|
||||
resetSearchResults();
|
||||
await nextTick();
|
||||
document.getElementById(props.inputId)?.focus();
|
||||
};
|
||||
|
||||
const handleSearchInput = () => {
|
||||
if (props.modelValue) {
|
||||
emit("update:modelValue", null);
|
||||
}
|
||||
|
||||
const query = searchQuery.value.trim();
|
||||
selectedResultIndex.value = -1;
|
||||
|
||||
if (!query) {
|
||||
showResults.value = false;
|
||||
resetSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
showResults.value = true;
|
||||
searchCustomer(query);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (searchQuery.value.trim() && hasResults.value) {
|
||||
showResults.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
window.setTimeout(() => {
|
||||
showResults.value = false;
|
||||
selectedResultIndex.value = -1;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
if (!showResults.value || !hasResults.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
selectedResultIndex.value = Math.min(selectedResultIndex.value + 1, searchCustomerResults.value.length - 1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
selectedResultIndex.value = Math.max(selectedResultIndex.value - 1, 0);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selectedCustomer = searchCustomerResults.value[selectedResultIndex.value] || searchCustomerResults.value[0];
|
||||
if (selectedCustomer) {
|
||||
setSelectedCustomer(selectedCustomer);
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
showResults.value = false;
|
||||
selectedResultIndex.value = -1;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(customer) => {
|
||||
if (customer) {
|
||||
searchQuery.value = formatCustomer(customer);
|
||||
} else if (!document.activeElement || document.activeElement.id !== props.inputId) {
|
||||
searchQuery.value = "";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetSearchResults();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customer-search-select">
|
||||
<div class="field">
|
||||
<label class="label" :for="inputId">{{ t("vehicles.add_modal.customer_label") }}</label>
|
||||
<div class="control has-icons-left" :class="{ 'is-loading': isSearching }">
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="searchQuery"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholderText"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="showResults && hasResults"
|
||||
:data-testid="`${testIdPrefix}-input`"
|
||||
@input="handleSearchInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showResults && hasResults"
|
||||
class="dropdown is-active customer-search-select__dropdown"
|
||||
:data-testid="`${testIdPrefix}-results`"
|
||||
>
|
||||
<div class="dropdown-menu customer-search-select__menu" role="listbox">
|
||||
<div class="dropdown-content">
|
||||
<button
|
||||
v-for="(customer, index) in searchCustomerResults"
|
||||
:key="getCustomerNumber(customer) || index"
|
||||
type="button"
|
||||
class="dropdown-item customer-search-select__option"
|
||||
:class="{ 'is-active': selectedResultIndex === index }"
|
||||
:data-testid="`${testIdPrefix}-option-${index}`"
|
||||
@mousedown.prevent="setSelectedCustomer(customer)"
|
||||
>
|
||||
<span class="customer-search-select__option-main">{{ getCustomerName(customer) }}</span>
|
||||
<span class="customer-search-select__option-meta">
|
||||
#{{ getCustomerNumber(customer) }}
|
||||
<template v-if="getCustomerCity(customer)"> · {{ getCustomerCity(customer) }}</template>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="modelValue" class="customer-search-select__selected" :data-testid="`${testIdPrefix}-selected`">
|
||||
<div>
|
||||
<p class="has-text-weight-semibold">{{ t("vehicles.add_modal.selected_customer") }}</p>
|
||||
<p>{{ selectedCustomerName }}</p>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
#{{ selectedCustomerNumber }}
|
||||
<span v-if="selectedCustomerCity"> · {{ selectedCustomerCity }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-light"
|
||||
:disabled="disabled"
|
||||
:data-testid="`${testIdPrefix}-clear`"
|
||||
@click="clearSelectedCustomer"
|
||||
>
|
||||
{{ t("common.clear") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-search-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.customer-search-select__dropdown,
|
||||
.customer-search-select__menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.customer-search-select__dropdown {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 4.75rem;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.customer-search-select__option {
|
||||
align-items: flex-start;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.customer-search-select__option-main {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.customer-search-select__option-meta {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.customer-search-select__selected {
|
||||
align-items: flex-start;
|
||||
background: #f5f8fc;
|
||||
border: 1px solid #d8e2ef;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -5932,20 +5932,6 @@
|
||||
},
|
||||
"vehicles": {
|
||||
"add": "@:{'words.generated.tilføj'} @:{'words.generated.køretøj'}",
|
||||
"add_modal": {
|
||||
"customer_label": "Kunde",
|
||||
"customer_placeholder": "Søg efter kundenavn eller kundenummer",
|
||||
"error": "Køretøjet kunne ikke tilføjes.",
|
||||
"no_customer_results": "Ingen kunder fundet",
|
||||
"reference_placeholder": "Valgfri reference",
|
||||
"registration_placeholder": "Registreringsnummer",
|
||||
"selected_customer": "Valgt kunde",
|
||||
"submit": "Tilføj køretøj",
|
||||
"title": "Tilføj køretøj",
|
||||
"type_load_error": "Køretøjstyper kunne ikke indlæses.",
|
||||
"type_placeholder": "Vælg køretøjstype",
|
||||
"validation_error": "Vælg kunde, registreringsnummer og køretøjstype."
|
||||
},
|
||||
"brand": "@:{'templates.generated.compat.admin.pos.make'}",
|
||||
"color": "Farve",
|
||||
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
|
||||
|
||||
@@ -5764,20 +5764,6 @@
|
||||
},
|
||||
"vehicles": {
|
||||
"add": "@.capitalize:{'words.generated.add'} @:{'words.generated.vehicle'}",
|
||||
"add_modal": {
|
||||
"customer_label": "Customer",
|
||||
"customer_placeholder": "Search by customer name or number",
|
||||
"error": "Unable to add vehicle.",
|
||||
"no_customer_results": "No customers found",
|
||||
"reference_placeholder": "Optional reference",
|
||||
"registration_placeholder": "Registration number",
|
||||
"selected_customer": "Selected customer",
|
||||
"submit": "Add vehicle",
|
||||
"title": "Add vehicle",
|
||||
"type_load_error": "Unable to load vehicle types.",
|
||||
"type_placeholder": "Select vehicle type",
|
||||
"validation_error": "Select a customer, registration number, and vehicle type."
|
||||
},
|
||||
"brand": "Brand",
|
||||
"color": "Color",
|
||||
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
|
||||
|
||||
@@ -4559,6 +4559,82 @@
|
||||
"subtitle": "@:{'templates.generated.compat.superuser_dashboard.departments.subtitle'}",
|
||||
"title": "@:common.departments"
|
||||
},
|
||||
"department_navigation": {
|
||||
"overview": "Overview",
|
||||
"modules": "Modules",
|
||||
"branding": "Profile & Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"department_overview": {
|
||||
"title": "Department overview",
|
||||
"subtitle": "Operational overview for the selected department",
|
||||
"loading": "Loading department overview",
|
||||
"date_from": "From",
|
||||
"date_to": "To",
|
||||
"range_label": "{from} to {to}",
|
||||
"empty_value": "-",
|
||||
"out_of": "of {total}",
|
||||
"presets": {
|
||||
"today": "Today",
|
||||
"last_seven_days": "Last 7 days"
|
||||
},
|
||||
"metrics": {
|
||||
"bookings": "Bookings",
|
||||
"complaints": "Complaints",
|
||||
"night_washes": "Night washes",
|
||||
"overtime": "Overtime",
|
||||
"products_sold": "Products sold",
|
||||
"revenue": "Revenue",
|
||||
"transactions": "Transactions",
|
||||
"washes": "Washes",
|
||||
"water_usage": "Water"
|
||||
},
|
||||
"units": {
|
||||
"hours": "h",
|
||||
"liters": "L"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product mix",
|
||||
"subtitle": "Tracked wash products in the selected period",
|
||||
"empty": "No product activity for the selected period"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Department profile",
|
||||
"no_description": "No department description",
|
||||
"department_id": "Department ID",
|
||||
"economic_department_id": "Economic department",
|
||||
"branding": "Branding",
|
||||
"created_at": "Created",
|
||||
"updated_at": "Updated"
|
||||
},
|
||||
"hardware": {
|
||||
"title": "Hardware readiness",
|
||||
"subtitle": "Gateway-backed lane and relay state",
|
||||
"gateways": "Gateways online",
|
||||
"lanes": "Lanes",
|
||||
"gates": "Gates",
|
||||
"relays": "Relays",
|
||||
"scanners": "Scanners",
|
||||
"issues": "Issues"
|
||||
},
|
||||
"quick_links": {
|
||||
"title": "Department tools",
|
||||
"subtitle": "Open the focused setup areas for this department",
|
||||
"modules": "Modules",
|
||||
"branding": "Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"errors": {
|
||||
"invalid_department": "A valid department is required",
|
||||
"load": "Unable to load the department overview"
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
"edit_employee_title": "@:employees.edit_employee",
|
||||
"new_employee_subtitle": "@:{'templates.generated.compat.superuser_dashboard.employees.new_employee_subtitle'}",
|
||||
@@ -5834,20 +5910,6 @@
|
||||
"vehicles": {
|
||||
"actions": "@:common.actions",
|
||||
"add": "@:{'templates.generated.compat.vehicles.add'}",
|
||||
"add_modal": {
|
||||
"customer_label": "@:{'templates.generated.compat.vehicles.add_modal.customer_label'}",
|
||||
"customer_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.customer_placeholder'}",
|
||||
"error": "@:{'templates.generated.compat.vehicles.add_modal.error'}",
|
||||
"no_customer_results": "@:{'templates.generated.compat.vehicles.add_modal.no_customer_results'}",
|
||||
"reference_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.reference_placeholder'}",
|
||||
"registration_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.registration_placeholder'}",
|
||||
"selected_customer": "@:{'templates.generated.compat.vehicles.add_modal.selected_customer'}",
|
||||
"submit": "@:{'templates.generated.compat.vehicles.add_modal.submit'}",
|
||||
"title": "@:{'templates.generated.compat.vehicles.add_modal.title'}",
|
||||
"type_load_error": "@:{'templates.generated.compat.vehicles.add_modal.type_load_error'}",
|
||||
"type_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.type_placeholder'}",
|
||||
"validation_error": "@:{'templates.generated.compat.vehicles.add_modal.validation_error'}"
|
||||
},
|
||||
"brand": "@:{'templates.generated.compat.vehicles.brand'}",
|
||||
"color": "@:{'templates.generated.compat.vehicles.color'}",
|
||||
"created_at": "@:{'templates.generated.compat.global.generated'}",
|
||||
|
||||
@@ -2,20 +2,6 @@
|
||||
"compat": {
|
||||
"vehicles": {
|
||||
"add": "@:{'terms.glossary.tilføj'} @:{'terms.glossary.køretøj'}",
|
||||
"add_modal": {
|
||||
"customer_label": "Kunde",
|
||||
"customer_placeholder": "Søg efter kundenavn eller kundenummer",
|
||||
"error": "Køretøjet kunne ikke tilføjes.",
|
||||
"no_customer_results": "Ingen kunder fundet",
|
||||
"reference_placeholder": "Valgfri reference",
|
||||
"registration_placeholder": "Registreringsnummer",
|
||||
"selected_customer": "Valgt kunde",
|
||||
"submit": "Tilføj køretøj",
|
||||
"title": "Tilføj køretøj",
|
||||
"type_load_error": "Køretøjstyper kunne ikke indlæses.",
|
||||
"type_placeholder": "Vælg køretøjstype",
|
||||
"validation_error": "Vælg kunde, registreringsnummer og køretøjstype."
|
||||
},
|
||||
"brand": "@:{'phrases.compat.admin.pos.make'}",
|
||||
"color": "Farve",
|
||||
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
|
||||
|
||||
@@ -2,20 +2,6 @@
|
||||
"compat": {
|
||||
"vehicles": {
|
||||
"add": "@.capitalize:{'terms.glossary.add'} @:{'terms.glossary.vehicle'}",
|
||||
"add_modal": {
|
||||
"customer_label": "Customer",
|
||||
"customer_placeholder": "Search by customer name or number",
|
||||
"error": "Unable to add vehicle.",
|
||||
"no_customer_results": "No customers found",
|
||||
"reference_placeholder": "Optional reference",
|
||||
"registration_placeholder": "Registration number",
|
||||
"selected_customer": "Selected customer",
|
||||
"submit": "Add vehicle",
|
||||
"title": "Add vehicle",
|
||||
"type_load_error": "Unable to load vehicle types.",
|
||||
"type_placeholder": "Select vehicle type",
|
||||
"validation_error": "Select a customer, registration number, and vehicle type."
|
||||
},
|
||||
"brand": "Brand",
|
||||
"color": "Color",
|
||||
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
|
||||
|
||||
@@ -40,6 +40,82 @@
|
||||
"subtitle": "@:{'phrases.compat.superuser_dashboard.departments.subtitle'}",
|
||||
"title": "@:common.departments"
|
||||
},
|
||||
"department_navigation": {
|
||||
"overview": "Overview",
|
||||
"modules": "Modules",
|
||||
"branding": "Profile & Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"department_overview": {
|
||||
"title": "Department overview",
|
||||
"subtitle": "Operational overview for the selected department",
|
||||
"loading": "Loading department overview",
|
||||
"date_from": "From",
|
||||
"date_to": "To",
|
||||
"range_label": "{from} to {to}",
|
||||
"empty_value": "-",
|
||||
"out_of": "of {total}",
|
||||
"presets": {
|
||||
"today": "Today",
|
||||
"last_seven_days": "Last 7 days"
|
||||
},
|
||||
"metrics": {
|
||||
"bookings": "Bookings",
|
||||
"complaints": "Complaints",
|
||||
"night_washes": "Night washes",
|
||||
"overtime": "Overtime",
|
||||
"products_sold": "Products sold",
|
||||
"revenue": "Revenue",
|
||||
"transactions": "Transactions",
|
||||
"washes": "Washes",
|
||||
"water_usage": "Water"
|
||||
},
|
||||
"units": {
|
||||
"hours": "h",
|
||||
"liters": "L"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product mix",
|
||||
"subtitle": "Tracked wash products in the selected period",
|
||||
"empty": "No product activity for the selected period"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Department profile",
|
||||
"no_description": "No department description",
|
||||
"department_id": "Department ID",
|
||||
"economic_department_id": "Economic department",
|
||||
"branding": "Branding",
|
||||
"created_at": "Created",
|
||||
"updated_at": "Updated"
|
||||
},
|
||||
"hardware": {
|
||||
"title": "Hardware readiness",
|
||||
"subtitle": "Gateway-backed lane and relay state",
|
||||
"gateways": "Gateways online",
|
||||
"lanes": "Lanes",
|
||||
"gates": "Gates",
|
||||
"relays": "Relays",
|
||||
"scanners": "Scanners",
|
||||
"issues": "Issues"
|
||||
},
|
||||
"quick_links": {
|
||||
"title": "Department tools",
|
||||
"subtitle": "Open the focused setup areas for this department",
|
||||
"modules": "Modules",
|
||||
"branding": "Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"errors": {
|
||||
"invalid_department": "A valid department is required",
|
||||
"load": "Unable to load the department overview"
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
"edit_employee_title": "@:employees.edit_employee",
|
||||
"new_employee_subtitle": "@:{'phrases.compat.superuser_dashboard.employees.new_employee_subtitle'}",
|
||||
|
||||
@@ -2,20 +2,6 @@
|
||||
"vehicles": {
|
||||
"actions": "@:common.actions",
|
||||
"add": "@:{'phrases.compat.vehicles.add'}",
|
||||
"add_modal": {
|
||||
"customer_label": "@:{'phrases.compat.vehicles.add_modal.customer_label'}",
|
||||
"customer_placeholder": "@:{'phrases.compat.vehicles.add_modal.customer_placeholder'}",
|
||||
"error": "@:{'phrases.compat.vehicles.add_modal.error'}",
|
||||
"no_customer_results": "@:{'phrases.compat.vehicles.add_modal.no_customer_results'}",
|
||||
"reference_placeholder": "@:{'phrases.compat.vehicles.add_modal.reference_placeholder'}",
|
||||
"registration_placeholder": "@:{'phrases.compat.vehicles.add_modal.registration_placeholder'}",
|
||||
"selected_customer": "@:{'phrases.compat.vehicles.add_modal.selected_customer'}",
|
||||
"submit": "@:{'phrases.compat.vehicles.add_modal.submit'}",
|
||||
"title": "@:{'phrases.compat.vehicles.add_modal.title'}",
|
||||
"type_load_error": "@:{'phrases.compat.vehicles.add_modal.type_load_error'}",
|
||||
"type_placeholder": "@:{'phrases.compat.vehicles.add_modal.type_placeholder'}",
|
||||
"validation_error": "@:{'phrases.compat.vehicles.add_modal.validation_error'}"
|
||||
},
|
||||
"brand": "@:{'phrases.compat.vehicles.brand'}",
|
||||
"color": "@:{'phrases.compat.vehicles.color'}",
|
||||
"created_at": "@:{'phrases.compat.global.generated'}",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
export const getSuperuserDepartmentOverview = (departmentId, { date, dateTo } = {}) => {
|
||||
const normalizedDepartmentId = Number.parseInt(String(departmentId ?? ""), 10);
|
||||
|
||||
if (!Number.isInteger(normalizedDepartmentId) || normalizedDepartmentId <= 0) {
|
||||
return Promise.reject(new Error("A valid department id is required."));
|
||||
}
|
||||
|
||||
const params = {
|
||||
date,
|
||||
};
|
||||
|
||||
if (dateTo) {
|
||||
params.date_to = dateTo;
|
||||
}
|
||||
|
||||
return authenticatedRequest(
|
||||
`/superuser/departments/${encodeURIComponent(String(normalizedDepartmentId))}/overview`,
|
||||
"GET",
|
||||
params
|
||||
);
|
||||
};
|
||||
@@ -1,53 +1,21 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { getOrders } from "@/components/shop/Orders.vue";
|
||||
import { showCreateOrderForm } from "@/components/forms/superUser/createOrderForm.vue";
|
||||
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import Orders from "@/components/displays/Orders.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import VehiclesPagination from "@/components/displays/pagination/models/UserDashboard/VehiclesPagination.vue";
|
||||
import SuperuserAddVehicleModal from "@/components/forms/superUser/SuperuserAddVehicleModal.vue";
|
||||
import { loadList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const isAddVehicleModalOpen = ref(false);
|
||||
|
||||
const openAddVehicleModal = () => {
|
||||
isAddVehicleModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeAddVehicleModal = () => {
|
||||
isAddVehicleModalOpen.value = false;
|
||||
};
|
||||
|
||||
const handleVehicleCreated = async () => {
|
||||
closeAddVehicleModal();
|
||||
await loadList();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<SuperUserDashboardNavigation />
|
||||
<VehiclesPagination>
|
||||
<template #actions>
|
||||
<button
|
||||
class="button is-link button-same-width superuser-vehicles__add-button"
|
||||
type="button"
|
||||
data-testid="superuser-vehicles-add"
|
||||
@click="openAddVehicleModal"
|
||||
>
|
||||
{{ $t("vehicles.add") }}
|
||||
</button>
|
||||
</template>
|
||||
</VehiclesPagination>
|
||||
<SuperuserAddVehicleModal
|
||||
v-if="isAddVehicleModalOpen"
|
||||
@close="closeAddVehicleModal"
|
||||
@created="handleVehicleCreated"
|
||||
/>
|
||||
<VehiclesPagination />
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.superuser-vehicles__add-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
</style>
|
||||
@@ -2,34 +2,871 @@
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { getEdgeGatewayDepartmentWorkspace } from "@/services/edgeGateways.js";
|
||||
import { getSuperuserDepartmentOverview } from "@/services/superuserDepartmentOverview.js";
|
||||
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
||||
import { setDepartment, department, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
// Get the department from the route
|
||||
const router = useRouter()
|
||||
setDepartment(router.currentRoute.value.params.departmentId);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const formatDateInput = (value = new Date()) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return formatDateInput(new Date());
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const addDays = (value, days) => {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return formatDateInput(date);
|
||||
};
|
||||
|
||||
const today = () => formatDateInput(new Date());
|
||||
const isDateValue = (value) => DATE_PATTERN.test(String(value || ""));
|
||||
const normalizeDate = (value, fallback = today()) => (isDateValue(value) ? String(value) : fallback);
|
||||
|
||||
const dateFrom = ref(normalizeDate(route.query.date));
|
||||
const dateTo = ref(normalizeDate(route.query.date_to, dateFrom.value));
|
||||
const department = ref(null);
|
||||
const overview = ref(null);
|
||||
const hardwareWorkspace = ref(null);
|
||||
const loading = ref(false);
|
||||
const hardwareLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const departmentId = computed(() => Number.parseInt(String(route.params.departmentId || ""), 10));
|
||||
const hasDepartmentId = computed(() => Number.isInteger(departmentId.value) && departmentId.value > 0);
|
||||
const pageTitle = computed(() => department.value?.name || t("superuser_dashboard.department_overview.title"));
|
||||
const pageSubtitle = computed(() => t("superuser_dashboard.department_overview.subtitle"));
|
||||
const overviewMetrics = computed(() => overview.value?.metrics || {});
|
||||
const productTiles = computed(() => (Array.isArray(overview.value?.products) ? overview.value.products : []));
|
||||
const periodLabel = computed(() =>
|
||||
dateFrom.value === dateTo.value
|
||||
? dateFrom.value
|
||||
: t("superuser_dashboard.department_overview.range_label", {
|
||||
from: dateFrom.value,
|
||||
to: dateTo.value,
|
||||
})
|
||||
);
|
||||
|
||||
const metricDefinitions = computed(() => [
|
||||
{
|
||||
key: "revenue",
|
||||
icon: "fa-sack-dollar",
|
||||
label: t("superuser_dashboard.department_overview.metrics.revenue"),
|
||||
formatter: formatCurrency,
|
||||
},
|
||||
{
|
||||
key: "washes",
|
||||
icon: "fa-truck",
|
||||
label: t("superuser_dashboard.department_overview.metrics.washes"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "transactions",
|
||||
icon: "fa-receipt",
|
||||
label: t("superuser_dashboard.department_overview.metrics.transactions"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "bookings",
|
||||
icon: "fa-calendar-check",
|
||||
label: t("superuser_dashboard.department_overview.metrics.bookings"),
|
||||
formatter: formatNumber,
|
||||
showOutOf: true,
|
||||
},
|
||||
{
|
||||
key: "products_sold",
|
||||
icon: "fa-boxes-stacked",
|
||||
label: t("superuser_dashboard.department_overview.metrics.products_sold"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "water_usage",
|
||||
icon: "fa-droplet",
|
||||
label: t("superuser_dashboard.department_overview.metrics.water_usage"),
|
||||
formatter: formatNumber,
|
||||
suffix: t("superuser_dashboard.department_overview.units.liters"),
|
||||
},
|
||||
{
|
||||
key: "complaints",
|
||||
icon: "fa-triangle-exclamation",
|
||||
label: t("superuser_dashboard.department_overview.metrics.complaints"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "night_washes",
|
||||
icon: "fa-moon",
|
||||
label: t("superuser_dashboard.department_overview.metrics.night_washes"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "overtime",
|
||||
icon: "fa-clock",
|
||||
label: t("superuser_dashboard.department_overview.metrics.overtime"),
|
||||
formatter: formatDecimal,
|
||||
suffix: t("superuser_dashboard.department_overview.units.hours"),
|
||||
},
|
||||
]);
|
||||
|
||||
const quickLinks = computed(() => [
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.modules"),
|
||||
icon: "fa-toggle-on",
|
||||
path: `/superuser/departments/${departmentId.value}/modules`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.branding"),
|
||||
icon: "fa-palette",
|
||||
path: `/superuser/departments/${departmentId.value}/branding`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.gateways"),
|
||||
icon: "fa-network-wired",
|
||||
path: `/superuser/departments/${departmentId.value}/gateways`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.stripe"),
|
||||
icon: "fa-credit-card",
|
||||
path: `/superuser/departments/${departmentId.value}/stripe/terminals/readers`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.pricing"),
|
||||
icon: "fa-tags",
|
||||
path: `/superuser/departments/${departmentId.value}/pricing`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.categories"),
|
||||
icon: "fa-layer-group",
|
||||
path: `/superuser/departments/${departmentId.value}/categories`,
|
||||
},
|
||||
]);
|
||||
|
||||
function toNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("da-DK").format(toNumber(value));
|
||||
}
|
||||
|
||||
function formatDecimal(value) {
|
||||
return new Intl.NumberFormat("da-DK", {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(toNumber(value));
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
const amount = toNumber(value);
|
||||
if (SessionUser.functions?.currency?.toLocal) {
|
||||
return SessionUser.functions.currency.toLocal(amount);
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("da-DK", {
|
||||
style: "currency",
|
||||
currency: "DKK",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return t("superuser_dashboard.department_overview.empty_value");
|
||||
}
|
||||
|
||||
if (SessionUser.functions?.date?.toLocal) {
|
||||
return SessionUser.functions.date.toLocal(value);
|
||||
}
|
||||
|
||||
const date = new Date(String(value).replace(" ", "T"));
|
||||
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat("da-DK").format(date);
|
||||
}
|
||||
|
||||
function metricPayload(key) {
|
||||
return overviewMetrics.value?.[key] || {
|
||||
state: "unavailable",
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
function metricValue(definition) {
|
||||
const metric = metricPayload(definition.key);
|
||||
if (metric.state && metric.state !== "ready") {
|
||||
return t("superuser_dashboard.department_overview.empty_value");
|
||||
}
|
||||
|
||||
const formatted = definition.formatter(metric.value);
|
||||
return definition.suffix ? `${formatted} ${definition.suffix}` : formatted;
|
||||
}
|
||||
|
||||
function metricSecondary(definition) {
|
||||
const metric = metricPayload(definition.key);
|
||||
if (!definition.showOutOf || metric.out_of === undefined || metric.out_of === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return t("superuser_dashboard.department_overview.out_of", {
|
||||
total: formatNumber(metric.out_of),
|
||||
});
|
||||
}
|
||||
|
||||
const asArray = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const hardwareSummary = computed(() => {
|
||||
const workspace = hardwareWorkspace.value || {};
|
||||
const gateways = asArray(workspace.gateways);
|
||||
const lanes = asArray(workspace.lanes);
|
||||
const gates = asArray(workspace.gates);
|
||||
const relays = asArray(workspace.relays);
|
||||
const scanners = asArray(workspace.scanners || workspace.number_plate_scanners);
|
||||
const issues = asArray(workspace.issues || workspace.diagnostics || workspace.hardware?.issues);
|
||||
const onlineGateways = gateways.filter((gateway) => {
|
||||
const status = String(gateway?.status || gateway?.health || "").toLowerCase();
|
||||
return ["online", "ready", "ok", "healthy"].includes(status);
|
||||
}).length;
|
||||
|
||||
return {
|
||||
gateways: gateways.length,
|
||||
onlineGateways,
|
||||
lanes: lanes.length,
|
||||
gates: gates.length,
|
||||
relays: relays.length,
|
||||
scanners: scanners.length,
|
||||
issues: issues.length,
|
||||
};
|
||||
});
|
||||
|
||||
const hasHardwareSummary = computed(() => hardwareLoading.value || Boolean(hardwareWorkspace.value));
|
||||
const departmentProfileRows = computed(() => [
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.department_id"),
|
||||
value: department.value?.id ?? t("superuser_dashboard.department_overview.empty_value"),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.economic_department_id"),
|
||||
value: department.value?.economic_department_id ?? t("superuser_dashboard.department_overview.empty_value"),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.branding"),
|
||||
value: department.value?.branding ?? t("superuser_dashboard.department_overview.empty_value"),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.created_at"),
|
||||
value: formatDateTime(department.value?.created_at),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.updated_at"),
|
||||
value: formatDateTime(department.value?.updated_at),
|
||||
},
|
||||
]);
|
||||
|
||||
function normalizeRange(from, to) {
|
||||
const normalizedFrom = normalizeDate(from);
|
||||
let normalizedTo = normalizeDate(to, normalizedFrom);
|
||||
|
||||
if (normalizedTo < normalizedFrom) {
|
||||
normalizedTo = normalizedFrom;
|
||||
}
|
||||
|
||||
return {
|
||||
from: normalizedFrom,
|
||||
to: normalizedTo,
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceRange(from, to) {
|
||||
const normalized = normalizeRange(from, to);
|
||||
dateFrom.value = normalized.from;
|
||||
dateTo.value = normalized.to;
|
||||
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
date: normalized.from,
|
||||
date_to: normalized.to === normalized.from ? undefined : normalized.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const applyDateInputs = () => replaceRange(dateFrom.value, dateTo.value);
|
||||
const setToday = () => replaceRange(today(), today());
|
||||
const setLastSevenDays = () => replaceRange(addDays(today(), -6), today());
|
||||
|
||||
async function loadOverview() {
|
||||
if (!hasDepartmentId.value) {
|
||||
errorMessage.value = t("superuser_dashboard.department_overview.errors.invalid_department");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await getSuperuserDepartmentOverview(departmentId.value, {
|
||||
date: dateFrom.value,
|
||||
dateTo: dateTo.value,
|
||||
});
|
||||
const payload = response?.data?.data || {};
|
||||
department.value = payload.department || null;
|
||||
overview.value = payload.overview || null;
|
||||
} catch (error) {
|
||||
department.value = null;
|
||||
overview.value = null;
|
||||
errorMessage.value =
|
||||
error?.response?.data?.message || error?.message || t("superuser_dashboard.department_overview.errors.load");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHardware() {
|
||||
if (!hasDepartmentId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
hardwareLoading.value = true;
|
||||
hardwareWorkspace.value = null;
|
||||
|
||||
try {
|
||||
const response = await getEdgeGatewayDepartmentWorkspace(departmentId.value);
|
||||
hardwareWorkspace.value = response?.data?.data || null;
|
||||
} catch (_error) {
|
||||
hardwareWorkspace.value = null;
|
||||
} finally {
|
||||
hardwareLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openQuickLink(path) {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.departmentId, route.query.date, route.query.date_to],
|
||||
() => {
|
||||
const normalized = normalizeRange(route.query.date, route.query.date_to || route.query.date);
|
||||
dateFrom.value = normalized.from;
|
||||
dateTo.value = normalized.to;
|
||||
loadOverview();
|
||||
loadHardware();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<DepartmentSubPageWrapper>
|
||||
<template #title>
|
||||
<PageTitle title="Department" subtitle="Department data" />
|
||||
<PageTitle :title="pageTitle" :subtitle="pageSubtitle" />
|
||||
</template>
|
||||
<div>
|
||||
Department stuff
|
||||
<code>{{ departmentId }}</code>
|
||||
<code>{{ department.id }}</code>
|
||||
<code>{{ department.name }}</code>
|
||||
<code>{{ department.description }}</code>
|
||||
<code>{{ department.created_at }}</code>
|
||||
<code>{{ department.updated_at }}</code>
|
||||
</div>
|
||||
|
||||
<section class="department-overview" data-testid="superuser-department-overview">
|
||||
<div class="overview-toolbar">
|
||||
<div class="overview-period">
|
||||
<label class="date-field">
|
||||
<span>{{ t("superuser_dashboard.department_overview.date_from") }}</span>
|
||||
<input
|
||||
v-model="dateFrom"
|
||||
class="input"
|
||||
data-testid="department-overview-date-from"
|
||||
type="date"
|
||||
@change="applyDateInputs"
|
||||
/>
|
||||
</label>
|
||||
<label class="date-field">
|
||||
<span>{{ t("superuser_dashboard.department_overview.date_to") }}</span>
|
||||
<input
|
||||
v-model="dateTo"
|
||||
class="input"
|
||||
data-testid="department-overview-date-to"
|
||||
type="date"
|
||||
@change="applyDateInputs"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="button is-light"
|
||||
data-testid="department-overview-preset-today"
|
||||
type="button"
|
||||
@click="setToday"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-calendar-day" /></span>
|
||||
<span>{{ t("superuser_dashboard.department_overview.presets.today") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-light"
|
||||
data-testid="department-overview-preset-week"
|
||||
type="button"
|
||||
@click="setLastSevenDays"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-calendar-week" /></span>
|
||||
<span>{{ t("superuser_dashboard.department_overview.presets.last_seven_days") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="period-chip" data-testid="department-overview-period">
|
||||
<span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span>
|
||||
<span>{{ periodLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-overview-error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !overview" class="overview-loading" data-testid="department-overview-loading">
|
||||
<span class="icon"><i class="fa-solid fa-spinner fa-spin" /></span>
|
||||
<span>{{ t("superuser_dashboard.department_overview.loading") }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="kpi-grid">
|
||||
<article
|
||||
v-for="metric in metricDefinitions"
|
||||
:key="metric.key"
|
||||
class="kpi-card"
|
||||
:data-testid="`department-overview-kpi-${metric.key}`"
|
||||
>
|
||||
<div class="kpi-icon">
|
||||
<i :class="['fa-solid', metric.icon]" />
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">{{ metric.label }}</span>
|
||||
<strong class="kpi-value">{{ metricValue(metric) }}</strong>
|
||||
<span v-if="metricSecondary(metric)" class="kpi-secondary">{{ metricSecondary(metric) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-main">
|
||||
<section class="overview-panel" data-testid="department-overview-products">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.products.title") }}</h2>
|
||||
<p>{{ t("superuser_dashboard.department_overview.products.subtitle") }}</p>
|
||||
</div>
|
||||
<span class="count-badge">{{ productTiles.length }}</span>
|
||||
</div>
|
||||
<div v-if="productTiles.length" class="product-list">
|
||||
<div v-for="product in productTiles" :key="product.slug || product.product_id" class="product-row">
|
||||
<div class="product-title">
|
||||
<strong>{{ product.title }}</strong>
|
||||
<span>{{ product.slug }}</span>
|
||||
</div>
|
||||
<div class="product-stat">
|
||||
<strong>{{ formatNumber(product.value) }}</strong>
|
||||
<span>
|
||||
{{
|
||||
t("superuser_dashboard.department_overview.out_of", {
|
||||
total: formatNumber(product.out_of),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
{{ t("superuser_dashboard.department_overview.products.empty") }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-panel" data-testid="department-overview-profile">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.profile.title") }}</h2>
|
||||
<p>{{ department?.description || t("superuser_dashboard.department_overview.profile.no_description") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="profile-list">
|
||||
<div v-for="row in departmentProfileRows" :key="row.label" class="profile-row">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="hasHardwareSummary" class="overview-panel" data-testid="department-overview-hardware">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.hardware.title") }}</h2>
|
||||
<p>{{ t("superuser_dashboard.department_overview.hardware.subtitle") }}</p>
|
||||
</div>
|
||||
<span v-if="hardwareLoading" class="icon"><i class="fa-solid fa-spinner fa-spin" /></span>
|
||||
</div>
|
||||
<div class="hardware-grid">
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.gateways") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.onlineGateways) }} / {{ formatNumber(hardwareSummary.gateways) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.lanes") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.lanes) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.gates") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.gates) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.relays") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.relays) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.scanners") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.scanners) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat" :class="{ 'has-issues': hardwareSummary.issues > 0 }">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.issues") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.issues) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-panel quick-links-panel" data-testid="department-overview-quick-links">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.quick_links.title") }}</h2>
|
||||
<p>{{ t("superuser_dashboard.department_overview.quick_links.subtitle") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-link-grid">
|
||||
<button
|
||||
v-for="link in quickLinks"
|
||||
:key="link.path"
|
||||
class="quick-link"
|
||||
type="button"
|
||||
@click="openQuickLink(link.path)"
|
||||
>
|
||||
<span class="icon"><i :class="['fa-solid', link.icon]" /></span>
|
||||
<span>{{ link.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</DepartmentSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.department-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
.overview-toolbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.overview-period {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.date-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.date-field span,
|
||||
.kpi-label,
|
||||
.kpi-secondary,
|
||||
.panel-heading-row p,
|
||||
.product-title span,
|
||||
.product-stat span,
|
||||
.hardware-stat span,
|
||||
.profile-row dt {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.period-chip {
|
||||
align-items: center;
|
||||
background: #eef2ff;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 8px;
|
||||
color: #3730a3;
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
gap: 0.35rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-loading,
|
||||
.empty-state {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
min-height: 5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
}
|
||||
|
||||
.kpi-card,
|
||||
.overview-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-height: 6rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
align-items: center;
|
||||
background: #ecfeff;
|
||||
border-radius: 8px;
|
||||
color: #0f766e;
|
||||
display: inline-flex;
|
||||
height: 2.5rem;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.kpi-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
color: #0f172a;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(18rem, 0.75fr);
|
||||
}
|
||||
|
||||
.overview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-heading-row {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-heading-row h2 {
|
||||
color: #0f172a;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-heading-row p {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
align-items: center;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 999px;
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
font-weight: 800;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
|
||||
.product-list,
|
||||
.profile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-row,
|
||||
.profile-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid #edf2f7;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
min-height: 3.25rem;
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
|
||||
.product-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-title strong {
|
||||
color: #1e293b;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.product-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 5.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.product-stat strong,
|
||||
.hardware-stat strong,
|
||||
.profile-row dd {
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-row dt,
|
||||
.profile-row dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-row dd {
|
||||
max-width: 58%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hardware-grid,
|
||||
.quick-link-grid {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.hardware-stat {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 4.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.hardware-stat.has-issues {
|
||||
background: #fff7ed;
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
|
||||
.quick-links-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.quick-link {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 8px;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: 800;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
min-height: 3rem;
|
||||
padding: 0 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quick-link:hover,
|
||||
.quick-link:focus {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.overview-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.period-chip {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.date-field,
|
||||
.overview-period .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hardware-grid,
|
||||
.quick-link-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-row,
|
||||
.profile-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.product-stat,
|
||||
.profile-row dd {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+55
-24
@@ -1,31 +1,60 @@
|
||||
<script setup>
|
||||
import { useRouter} from "vue-router";
|
||||
import { ref } from 'vue';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
// Get the user from the route
|
||||
const departmentId = ref(parseInt(router.currentRoute.value.params.departmentId))
|
||||
const { t } = useI18n();
|
||||
|
||||
const tabs = [
|
||||
{ name: 'Overblik', path: '/superuser/departments/' + departmentId.value },
|
||||
{ name: 'Moduler', path: '/superuser/departments/' + departmentId.value + '/modules' },
|
||||
{ name: 'Profil & Branding', path: '/superuser/departments/' + departmentId.value + '/branding' },
|
||||
{ name: 'Gateways', path: '/superuser/departments/' + departmentId.value + '/gateways' },
|
||||
{ name: 'Stripe', path: '/superuser/departments/' + departmentId.value + '/stripe/terminals/readers' },
|
||||
{ name: 'Priser', path: '/superuser/departments/' + departmentId.value + '/pricing' },
|
||||
{ name: SessionUser.objects.categories.meta.title, path: '/superuser/departments/' + departmentId.value + '/categories' },
|
||||
];
|
||||
const departmentId = computed(() => String(route.params.departmentId || ""));
|
||||
const departmentPath = computed(() => `/superuser/departments/${encodeURIComponent(departmentId.value)}`);
|
||||
|
||||
// Get the current path
|
||||
const currentPath = ref(router.currentRoute.value.path);
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.overview"),
|
||||
path: departmentPath.value,
|
||||
active: (path) => path === departmentPath.value,
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.modules"),
|
||||
path: `${departmentPath.value}/modules`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/modules`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.branding"),
|
||||
path: `${departmentPath.value}/branding`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/branding`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.gateways"),
|
||||
path: `${departmentPath.value}/gateways`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/gateways`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.stripe"),
|
||||
path: `${departmentPath.value}/stripe/terminals/readers`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/stripe`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.pricing"),
|
||||
path: `${departmentPath.value}/pricing`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/pricing`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.categories"),
|
||||
path: `${departmentPath.value}/categories`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/categories`),
|
||||
},
|
||||
]);
|
||||
|
||||
// Get the index of the active tab
|
||||
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
|
||||
const activeTab = computed(() => tabs.value.findIndex((tab) => tab.active(route.path)));
|
||||
|
||||
// Change the tab
|
||||
const changeTab = (index) => {
|
||||
router.push(tabs[index].path);
|
||||
const tab = tabs.value[index];
|
||||
if (tab) {
|
||||
router.push(tab.path);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -33,13 +62,15 @@ const changeTab = (index) => {
|
||||
<div>
|
||||
<div class="tabs is-right">
|
||||
<ul>
|
||||
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
|
||||
<li
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.path"
|
||||
:class="{ 'is-active': activeTab === index }"
|
||||
@click="changeTab(index)"
|
||||
>
|
||||
<a>{{ tab.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
const json = (body, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function installOverviewRoutes(page) {
|
||||
await page.route(apiPathPattern("/superuser/departments/1/overview"), async (route) => {
|
||||
const parsedUrl = new URL(route.request().url());
|
||||
expect(parsedUrl.searchParams.get("date")).toBe("2026-07-06");
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
department: {
|
||||
id: 1,
|
||||
name: "Esbjerg",
|
||||
description: "Skagerrakvej 15",
|
||||
economic_department_id: 42,
|
||||
branding: 14,
|
||||
created_at: "2026-01-01 00:00:00",
|
||||
updated_at: "2026-07-06 09:30:00",
|
||||
},
|
||||
overview: {
|
||||
department_ids: [1],
|
||||
date: "2026-07-06",
|
||||
date_to: "2026-07-06",
|
||||
metrics: {
|
||||
bookings: { state: "ready", value: 3, out_of: 4 },
|
||||
complaints: { state: "ready", value: 1 },
|
||||
night_washes: { state: "ready", value: 2 },
|
||||
revenue: { state: "ready", value: 1234 },
|
||||
washes: { state: "ready", value: 11 },
|
||||
products_sold: { state: "ready", value: 18 },
|
||||
transactions: { state: "ready", value: 9 },
|
||||
water_usage: { state: "ready", value: 250 },
|
||||
overtime: { state: "ready", value: 1.5 },
|
||||
},
|
||||
products: [
|
||||
{
|
||||
product_id: 24,
|
||||
slug: "spot-free-lastbil",
|
||||
title: "Spot Free",
|
||||
state: "ready",
|
||||
value: 4,
|
||||
out_of: 11,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(apiPathPattern("/modules/edge-gateways/workspace/departments/1"), async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
gateways: [
|
||||
{ id: 1, status: "ONLINE" },
|
||||
{ id: 2, status: "OFFLINE" },
|
||||
],
|
||||
lanes: [{ id: 1 }, { id: 2 }],
|
||||
gates: [{ id: 1 }],
|
||||
relays: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
scanners: [{ id: 1 }],
|
||||
issues: [{ key: "gateway-offline" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("superuser department overview", () => {
|
||||
test("renders the operational overview for a selected department", async ({ page }) => {
|
||||
await seedAuthenticatedState(page, "superuser-department-overview-token");
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
});
|
||||
await installOverviewRoutes(page);
|
||||
|
||||
await page.goto("/superuser/departments/1?date=2026-07-06", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("superuser-department-overview")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Esbjerg" })).toBeVisible();
|
||||
await expect(page.getByTestId("department-overview-kpi-revenue")).toContainText("1.234");
|
||||
await expect(page.getByTestId("department-overview-kpi-bookings")).toContainText("of 4");
|
||||
await expect(page.getByTestId("department-overview-products")).toContainText("Spot Free");
|
||||
await expect(page.getByTestId("department-overview-hardware")).toContainText("1 / 2");
|
||||
await expect(page.getByTestId("department-overview-quick-links")).toContainText("Gateways");
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,10 @@ async function primeSuperuserSession(page) {
|
||||
}
|
||||
|
||||
test.describe("Superuser vehicles smoke", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
pos: true,
|
||||
});
|
||||
await primeSuperuserSession(page);
|
||||
});
|
||||
@@ -26,51 +23,4 @@ test.describe("Superuser vehicles smoke", () => {
|
||||
await expect(page.locator("body")).toContainText(/registrerede|registered/i);
|
||||
await expect(page.locator("body")).not.toContainText(/Order ID is required/i);
|
||||
});
|
||||
|
||||
test("superuser can add a vehicle after selecting a customer from searchable results", async ({ page }) => {
|
||||
const createVehiclePayloads = [];
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.endsWith("/vehicles") && request.method() === "POST") {
|
||||
createVehiclePayloads.push(request.postDataJSON());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/superuser/vehicles");
|
||||
|
||||
await page.getByTestId("superuser-vehicles-add").click();
|
||||
await expect(page.getByTestId("superuser-add-vehicle-modal")).toBeVisible();
|
||||
|
||||
await page.getByTestId("superuser-add-vehicle-customer-input").fill("12345679");
|
||||
await expect(page.getByTestId("superuser-add-vehicle-customer-option-0")).toBeVisible();
|
||||
await page.getByTestId("superuser-add-vehicle-customer-option-0").click();
|
||||
await expect(page.getByTestId("superuser-add-vehicle-customer-selected")).toContainText("#12345679");
|
||||
|
||||
await page.getByTestId("superuser-add-vehicle-registration").fill("ab12345");
|
||||
await expect(page.getByTestId("superuser-add-vehicle-type")).toBeEnabled();
|
||||
await page.getByTestId("superuser-add-vehicle-type").selectOption("53");
|
||||
await page.getByTestId("superuser-add-vehicle-wash-subscription").check();
|
||||
await page.getByTestId("superuser-add-vehicle-reference").fill("Fleet reference");
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes("/vehicles") && response.request().method() === "POST"
|
||||
),
|
||||
page.getByTestId("superuser-add-vehicle-submit").click(),
|
||||
]);
|
||||
|
||||
expect(createVehiclePayloads).toEqual([
|
||||
{
|
||||
type: 53,
|
||||
reg: "AB12345",
|
||||
wash_subscription: true,
|
||||
customer_id: 12345679,
|
||||
reference: "Fleet reference",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId("superuser-add-vehicle-modal")).toBeHidden();
|
||||
await expect(page.getByTestId("user-vehicles-table")).toContainText("AB12345");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2736,7 +2736,6 @@ export function createPosFixture(overrides = {}) {
|
||||
last_order_id: 54518,
|
||||
},
|
||||
],
|
||||
nextVehicleId: 7002,
|
||||
unknownVehicles: [],
|
||||
orderBookings: [],
|
||||
bookingOrderAssignments: [],
|
||||
@@ -3279,30 +3278,6 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/vehicles") && method === "POST") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const customerId = Number(body.customer_id || 0);
|
||||
const customer = posFixture.customersByNumber[customerId] || null;
|
||||
const vehicle = {
|
||||
id: posFixture.nextVehicleId || 9001,
|
||||
reg: String(body.reg || "").toUpperCase(),
|
||||
customer_id: customerId,
|
||||
customer_name: customer?.name || "",
|
||||
type: Number(body.type || 0),
|
||||
status: "verified",
|
||||
barred: false,
|
||||
wash_subscription: Boolean(body.wash_subscription),
|
||||
addons: { enabled: 0, available: 0, list: [] },
|
||||
reference: body.reference || null,
|
||||
};
|
||||
|
||||
posFixture.nextVehicleId = vehicle.id + 1;
|
||||
posFixture.vehicles = [vehicle, ...(posFixture.vehicles || [])];
|
||||
|
||||
await route.fulfill(json({ success: true, data: vehicle }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/department/vehicles/unknown-customer") && method === "GET") {
|
||||
await route.fulfill(json({ success: true, data: posFixture.unknownVehicles || [] }));
|
||||
return true;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { nextTick } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
|
||||
const searchMocks = vi.hoisted(() => ({
|
||||
isSearchingRef: null,
|
||||
resultsRef: null,
|
||||
searchCustomer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/search/economic/customerSearch.vue", async () => {
|
||||
const { ref } = await vi.importActual("vue");
|
||||
|
||||
searchMocks.isSearchingRef = ref(false);
|
||||
searchMocks.resultsRef = ref([]);
|
||||
|
||||
return {
|
||||
isSearching: searchMocks.isSearchingRef,
|
||||
searchCustomerResults: searchMocks.resultsRef,
|
||||
searchCustomer: searchMocks.searchCustomer,
|
||||
};
|
||||
});
|
||||
|
||||
import CustomerSearchSelect from "@/components/search/economic/CustomerSearchSelect.vue";
|
||||
|
||||
const customers = [
|
||||
{
|
||||
customerNumber: 12345679,
|
||||
name: "Acme Transport",
|
||||
city: "Taastrup",
|
||||
},
|
||||
{
|
||||
customerNumber: 87654321,
|
||||
name: "Nordic Wash",
|
||||
city: "Copenhagen",
|
||||
},
|
||||
];
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
vehicles: {
|
||||
add_modal: {
|
||||
customer_label: "Customer",
|
||||
customer_placeholder: "Search customers",
|
||||
selected_customer: "Selected customer",
|
||||
},
|
||||
},
|
||||
common: {
|
||||
clear: "Clear",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mountComponent = (props = {}) =>
|
||||
mountWithApp(CustomerSearchSelect, {
|
||||
props: {
|
||||
inputId: "test-customer-search",
|
||||
testIdPrefix: "test-customer",
|
||||
...props,
|
||||
},
|
||||
messages,
|
||||
});
|
||||
|
||||
describe("CustomerSearchSelect", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
searchMocks.isSearchingRef.value = false;
|
||||
searchMocks.resultsRef.value = [];
|
||||
searchMocks.searchCustomer.mockImplementation((query) => {
|
||||
searchMocks.resultsRef.value = query ? customers : [];
|
||||
return Promise.resolve(searchMocks.resultsRef.value);
|
||||
});
|
||||
});
|
||||
|
||||
it("searches customers and emits the selected customer", async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
await wrapper.get('[data-testid="test-customer-input"]').setValue("acme");
|
||||
await nextTick();
|
||||
|
||||
expect(searchMocks.searchCustomer).toHaveBeenLastCalledWith("acme");
|
||||
expect(wrapper.get('[data-testid="test-customer-option-0"]').text()).toContain("Acme Transport");
|
||||
|
||||
await wrapper.get('[data-testid="test-customer-option-0"]').trigger("mousedown");
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([customers[0]]);
|
||||
expect(wrapper.emitted("selected").at(-1)).toEqual([customers[0]]);
|
||||
await wrapper.setProps({ modelValue: customers[0] });
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.get('[data-testid="test-customer-selected"]').text()).toContain("Acme Transport");
|
||||
});
|
||||
|
||||
it("supports keyboard selection and clearing", async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
await wrapper.get('[data-testid="test-customer-input"]').setValue("nordic");
|
||||
await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "ArrowDown" });
|
||||
await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "ArrowDown" });
|
||||
await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "Enter" });
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([customers[1]]);
|
||||
await wrapper.setProps({ modelValue: customers[1] });
|
||||
await nextTick();
|
||||
|
||||
await wrapper.get('[data-testid="test-customer-clear"]').trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([null]);
|
||||
expect(wrapper.emitted("cleared")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { nextTick } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
addVehicle: vi.fn(),
|
||||
parseErrorMessage: vi.fn(),
|
||||
vehicleTypeOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => {
|
||||
const sessionUser = {
|
||||
functions: {
|
||||
parseErrorMessage: sessionMocks.parseErrorMessage,
|
||||
},
|
||||
objects: {
|
||||
vehicles: {
|
||||
add: sessionMocks.addVehicle,
|
||||
columns: {
|
||||
type: {
|
||||
options: sessionMocks.vehicleTypeOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
SessionUser: sessionUser,
|
||||
default: sessionUser,
|
||||
};
|
||||
});
|
||||
|
||||
import SuperuserAddVehicleModal from "@/components/forms/superUser/SuperuserAddVehicleModal.vue";
|
||||
|
||||
const CustomerSearchSelectStub = {
|
||||
props: ["modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
data-testid="customer-select-stub"
|
||||
@click="$emit('update:modelValue', { customerNumber: 12345679, name: 'Acme Transport' })"
|
||||
>
|
||||
Select customer
|
||||
</button>
|
||||
`,
|
||||
};
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
vehicles: {
|
||||
add_modal: {
|
||||
customer_label: "Customer",
|
||||
customer_placeholder: "Search customers",
|
||||
error: "Unable to add vehicle.",
|
||||
reference_placeholder: "Optional reference",
|
||||
registration_placeholder: "Registration number",
|
||||
selected_customer: "Selected customer",
|
||||
submit: "Add vehicle",
|
||||
title: "Add vehicle",
|
||||
type_load_error: "Unable to load vehicle types.",
|
||||
type_placeholder: "Select vehicle type",
|
||||
validation_error: "Select required fields.",
|
||||
},
|
||||
form: {
|
||||
license_plate: "Registration",
|
||||
type: "Type",
|
||||
},
|
||||
},
|
||||
objects: {
|
||||
vehicles: {
|
||||
columns: {
|
||||
wash_subscription: "Wash subscription",
|
||||
},
|
||||
},
|
||||
},
|
||||
common: {
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
reference: "Reference",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const flushAll = async () => {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
const mountComponent = () =>
|
||||
mountWithApp(SuperuserAddVehicleModal, {
|
||||
messages,
|
||||
global: {
|
||||
stubs: {
|
||||
CustomerSearchSelect: CustomerSearchSelectStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("SuperuserAddVehicleModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionMocks.vehicleTypeOptions.mockResolvedValue([
|
||||
{
|
||||
id: 53,
|
||||
name: "Forvogn",
|
||||
},
|
||||
]);
|
||||
sessionMocks.addVehicle.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
id: 7002,
|
||||
},
|
||||
},
|
||||
});
|
||||
sessionMocks.parseErrorMessage.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("submits the selected customer and vehicle fields through the vehicle API", async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushAll();
|
||||
|
||||
await wrapper.get('[data-testid="customer-select-stub"]').trigger("click");
|
||||
await wrapper.get('[data-testid="superuser-add-vehicle-registration"]').setValue("ab12345");
|
||||
await wrapper.get('[data-testid="superuser-add-vehicle-type"]').setValue("53");
|
||||
await wrapper.get('[data-testid="superuser-add-vehicle-wash-subscription"]').setValue(true);
|
||||
await wrapper.get('[data-testid="superuser-add-vehicle-reference"]').setValue("Fleet ref");
|
||||
await wrapper.get('[data-testid="superuser-add-vehicle-submit"]').trigger("click");
|
||||
await flushAll();
|
||||
|
||||
expect(sessionMocks.addVehicle).toHaveBeenCalledWith(53, "AB12345", true, 12345679, "Fleet ref");
|
||||
expect(wrapper.emitted("created")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps submit disabled until required fields are present", async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushAll();
|
||||
|
||||
expect(wrapper.get('[data-testid="superuser-add-vehicle-submit"]').attributes("disabled")).toBeDefined();
|
||||
|
||||
await wrapper.get('[data-testid="customer-select-stub"]').trigger("click");
|
||||
await wrapper.get('[data-testid="superuser-add-vehicle-registration"]').setValue("AB12345");
|
||||
await wrapper.get('[data-testid="superuser-add-vehicle-type"]').setValue("53");
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.get('[data-testid="superuser-add-vehicle-submit"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { nextTick } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
route: {
|
||||
params: { departmentId: "1" },
|
||||
query: { date: "2026-07-06", date_to: "2026-07-06" },
|
||||
path: "/superuser/departments/1",
|
||||
},
|
||||
router: {
|
||||
replace: vi.fn((nextRoute) => {
|
||||
mocks.route.query = nextRoute.query || {};
|
||||
return Promise.resolve();
|
||||
}),
|
||||
push: vi.fn(),
|
||||
},
|
||||
getSuperuserDepartmentOverview: vi.fn(),
|
||||
getEdgeGatewayDepartmentWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRoute: () => mocks.route,
|
||||
useRouter: () => mocks.router,
|
||||
}));
|
||||
|
||||
vi.mock("@/services/superuserDepartmentOverview.js", () => ({
|
||||
getSuperuserDepartmentOverview: mocks.getSuperuserDepartmentOverview,
|
||||
}));
|
||||
|
||||
vi.mock("@/services/edgeGateways.js", () => ({
|
||||
getEdgeGatewayDepartmentWorkspace: mocks.getEdgeGatewayDepartmentWorkspace,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
canAccessSuperUser: () => true,
|
||||
functions: {
|
||||
currency: {
|
||||
toLocal: (value) => `${Number(value).toLocaleString("da-DK")} kr.`,
|
||||
},
|
||||
date: {
|
||||
toLocal: (value) => `local:${value}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/global/PageTitle.vue", () => ({
|
||||
default: {
|
||||
props: ["title", "subtitle"],
|
||||
template: "<header><h1>{{ title }}</h1><p>{{ subtitle }}</p></header>",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/page/wrappers/RestrictedPageWrapper.vue", () => ({
|
||||
default: {
|
||||
template: "<div><slot /></div>",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue", () => ({
|
||||
default: {
|
||||
template: "<main><slot name='title' /><slot /></main>",
|
||||
},
|
||||
}));
|
||||
|
||||
import DepartmentOverview from "@/views/dashboards/superUserDashboard/department/Department.vue";
|
||||
import DepartmentNavigation from "@/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue";
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
superuser_dashboard: {
|
||||
department_navigation: {
|
||||
overview: "Overview",
|
||||
modules: "Modules",
|
||||
branding: "Profile & Branding",
|
||||
gateways: "Gateways",
|
||||
stripe: "Stripe",
|
||||
pricing: "Pricing",
|
||||
categories: "Categories",
|
||||
},
|
||||
department_overview: {
|
||||
title: "Department overview",
|
||||
subtitle: "Operational overview",
|
||||
loading: "Loading department overview",
|
||||
date_from: "From",
|
||||
date_to: "To",
|
||||
range_label: "{from} to {to}",
|
||||
empty_value: "-",
|
||||
out_of: "of {total}",
|
||||
presets: {
|
||||
today: "Today",
|
||||
last_seven_days: "Last 7 days",
|
||||
},
|
||||
metrics: {
|
||||
bookings: "Bookings",
|
||||
complaints: "Complaints",
|
||||
night_washes: "Night washes",
|
||||
overtime: "Overtime",
|
||||
products_sold: "Products sold",
|
||||
revenue: "Revenue",
|
||||
transactions: "Transactions",
|
||||
washes: "Washes",
|
||||
water_usage: "Water",
|
||||
},
|
||||
units: {
|
||||
hours: "h",
|
||||
liters: "L",
|
||||
},
|
||||
products: {
|
||||
title: "Product mix",
|
||||
subtitle: "Tracked wash products",
|
||||
empty: "No product activity",
|
||||
},
|
||||
profile: {
|
||||
title: "Department profile",
|
||||
no_description: "No department description",
|
||||
department_id: "Department ID",
|
||||
economic_department_id: "Economic department",
|
||||
branding: "Branding",
|
||||
created_at: "Created",
|
||||
updated_at: "Updated",
|
||||
},
|
||||
hardware: {
|
||||
title: "Hardware readiness",
|
||||
subtitle: "Gateway-backed state",
|
||||
gateways: "Gateways online",
|
||||
lanes: "Lanes",
|
||||
gates: "Gates",
|
||||
relays: "Relays",
|
||||
scanners: "Scanners",
|
||||
issues: "Issues",
|
||||
},
|
||||
quick_links: {
|
||||
title: "Department tools",
|
||||
subtitle: "Open setup areas",
|
||||
modules: "Modules",
|
||||
branding: "Branding",
|
||||
gateways: "Gateways",
|
||||
stripe: "Stripe",
|
||||
pricing: "Pricing",
|
||||
categories: "Categories",
|
||||
},
|
||||
errors: {
|
||||
invalid_department: "A valid department is required",
|
||||
load: "Unable to load the department overview",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const overviewResponse = {
|
||||
data: {
|
||||
data: {
|
||||
department: {
|
||||
id: 1,
|
||||
name: "Esbjerg",
|
||||
description: "Skagerrakvej 15",
|
||||
economic_department_id: 42,
|
||||
branding: 14,
|
||||
created_at: "2026-01-01 00:00:00",
|
||||
updated_at: "2026-07-06 09:30:00",
|
||||
},
|
||||
overview: {
|
||||
department_ids: [1],
|
||||
date: "2026-07-06",
|
||||
date_to: "2026-07-06",
|
||||
metrics: {
|
||||
bookings: { state: "ready", value: 3, out_of: 4 },
|
||||
complaints: { state: "ready", value: 1 },
|
||||
night_washes: { state: "ready", value: 2 },
|
||||
revenue: { state: "ready", value: 1234 },
|
||||
washes: { state: "ready", value: 11 },
|
||||
products_sold: { state: "ready", value: 18 },
|
||||
transactions: { state: "ready", value: 9 },
|
||||
water_usage: { state: "ready", value: 250 },
|
||||
overtime: { state: "ready", value: 1.5 },
|
||||
},
|
||||
products: [
|
||||
{
|
||||
product_id: 24,
|
||||
slug: "spot-free-lastbil",
|
||||
title: "Spot Free",
|
||||
state: "ready",
|
||||
value: 4,
|
||||
out_of: 11,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const hardwareResponse = {
|
||||
data: {
|
||||
data: {
|
||||
gateways: [
|
||||
{ id: 1, status: "ONLINE" },
|
||||
{ id: 2, status: "OFFLINE" },
|
||||
],
|
||||
lanes: [{ id: 1 }, { id: 2 }],
|
||||
gates: [{ id: 1 }],
|
||||
relays: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
scanners: [{ id: 1 }],
|
||||
issues: [{ key: "gateway-offline" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const flushRendering = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.route.params = { departmentId: "1" };
|
||||
mocks.route.query = { date: "2026-07-06", date_to: "2026-07-06" };
|
||||
mocks.route.path = "/superuser/departments/1";
|
||||
mocks.router.replace.mockClear();
|
||||
mocks.router.push.mockClear();
|
||||
mocks.getSuperuserDepartmentOverview.mockReset();
|
||||
mocks.getEdgeGatewayDepartmentWorkspace.mockReset();
|
||||
mocks.getSuperuserDepartmentOverview.mockResolvedValue(overviewResponse);
|
||||
mocks.getEdgeGatewayDepartmentWorkspace.mockResolvedValue(hardwareResponse);
|
||||
});
|
||||
|
||||
describe("Superuser department overview", () => {
|
||||
it("loads the superuser overview endpoint and renders operations data", async () => {
|
||||
const wrapper = mountWithApp(DepartmentOverview, {
|
||||
messages,
|
||||
});
|
||||
|
||||
await flushRendering();
|
||||
|
||||
expect(mocks.getSuperuserDepartmentOverview).toHaveBeenCalledWith(1, {
|
||||
date: "2026-07-06",
|
||||
dateTo: "2026-07-06",
|
||||
});
|
||||
expect(mocks.getEdgeGatewayDepartmentWorkspace).toHaveBeenCalledWith(1);
|
||||
expect(wrapper.find("h1").text()).toBe("Esbjerg");
|
||||
expect(wrapper.get('[data-testid="department-overview-kpi-revenue"]').text()).toContain("1.234 kr.");
|
||||
expect(wrapper.get('[data-testid="department-overview-kpi-bookings"]').text()).toContain("of 4");
|
||||
expect(wrapper.get('[data-testid="department-overview-products"]').text()).toContain("Spot Free");
|
||||
expect(wrapper.get('[data-testid="department-overview-profile"]').text()).toContain("Economic department");
|
||||
expect(wrapper.get('[data-testid="department-overview-hardware"]').text()).toContain("1 / 2");
|
||||
});
|
||||
|
||||
it("keeps the overview visible when the optional hardware summary is unavailable", async () => {
|
||||
mocks.getEdgeGatewayDepartmentWorkspace.mockRejectedValue(new Error("Forbidden"));
|
||||
|
||||
const wrapper = mountWithApp(DepartmentOverview, {
|
||||
messages,
|
||||
});
|
||||
|
||||
await flushRendering();
|
||||
|
||||
expect(wrapper.get('[data-testid="department-overview-kpi-revenue"]').text()).toContain("1.234 kr.");
|
||||
expect(wrapper.find('[data-testid="department-overview-hardware"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses translated reactive department tabs and pushes the selected route", async () => {
|
||||
mocks.route.path = "/superuser/departments/1/gateways";
|
||||
|
||||
const wrapper = mountWithApp(DepartmentNavigation, { messages });
|
||||
const activeTab = wrapper.find(".tabs li.is-active");
|
||||
|
||||
expect(activeTab.text()).toBe("Gateways");
|
||||
|
||||
const tabs = wrapper.findAll(".tabs li");
|
||||
await tabs.find((tab) => tab.text() === "Pricing").trigger("click");
|
||||
|
||||
expect(mocks.router.push).toHaveBeenCalledWith("/superuser/departments/1/pricing");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user