Build superuser department overview page
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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