Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da8c01db5 | ||
|
|
3725c2e52c | ||
|
|
502a97fb52 |
@@ -140,6 +140,7 @@ jobs:
|
|||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
HEAD_SHA: ${{ github.sha }}
|
HEAD_SHA: ${{ github.sha }}
|
||||||
PR_BASE_SHA: ${{ github.event.pull_request.base.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 }}
|
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -152,8 +153,13 @@ jobs:
|
|||||||
else
|
else
|
||||||
base_ref="$PUSH_BEFORE_SHA"
|
base_ref="$PUSH_BEFORE_SHA"
|
||||||
fi
|
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 "base=$base_ref" >> "$GITHUB_OUTPUT"
|
||||||
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ export const sourceMappings = [
|
|||||||
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
|
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
|
||||||
projects: chromiumProjects,
|
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",
|
name: "booking",
|
||||||
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const ownedFilesByRole = {
|
|||||||
"superuser-customer-complaints.spec.ts",
|
"superuser-customer-complaints.spec.ts",
|
||||||
"superuser-customers-mass-import.spec.ts",
|
"superuser-customers-mass-import.spec.ts",
|
||||||
"superuser-department-branding.spec.js",
|
"superuser-department-branding.spec.js",
|
||||||
|
"superuser-department-overview.spec.js",
|
||||||
"superuser-department-gates.spec.ts",
|
"superuser-department-gates.spec.ts",
|
||||||
"superuser-department-lanes.spec.ts",
|
"superuser-department-lanes.spec.ts",
|
||||||
"superuser-departments-archive.spec.ts",
|
"superuser-departments-archive.spec.ts",
|
||||||
|
|||||||
@@ -4559,6 +4559,82 @@
|
|||||||
"subtitle": "@:{'templates.generated.compat.superuser_dashboard.departments.subtitle'}",
|
"subtitle": "@:{'templates.generated.compat.superuser_dashboard.departments.subtitle'}",
|
||||||
"title": "@:common.departments"
|
"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": {
|
"employees": {
|
||||||
"edit_employee_title": "@:employees.edit_employee",
|
"edit_employee_title": "@:employees.edit_employee",
|
||||||
"new_employee_subtitle": "@:{'templates.generated.compat.superuser_dashboard.employees.new_employee_subtitle'}",
|
"new_employee_subtitle": "@:{'templates.generated.compat.superuser_dashboard.employees.new_employee_subtitle'}",
|
||||||
|
|||||||
@@ -40,6 +40,82 @@
|
|||||||
"subtitle": "@:{'phrases.compat.superuser_dashboard.departments.subtitle'}",
|
"subtitle": "@:{'phrases.compat.superuser_dashboard.departments.subtitle'}",
|
||||||
"title": "@:common.departments"
|
"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": {
|
"employees": {
|
||||||
"edit_employee_title": "@:employees.edit_employee",
|
"edit_employee_title": "@:employees.edit_employee",
|
||||||
"new_employee_subtitle": "@:{'phrases.compat.superuser_dashboard.employees.new_employee_subtitle'}",
|
"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 PageTitle from "@/components/global/PageTitle.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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 DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
||||||
import { setDepartment, department, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRouter } from 'vue-router'
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
// Get the department from the route
|
const route = useRoute();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
setDepartment(router.currentRoute.value.params.departmentId);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||||
<DepartmentSubPageWrapper>
|
<DepartmentSubPageWrapper>
|
||||||
<template #title>
|
<template #title>
|
||||||
<PageTitle title="Department" subtitle="Department data" />
|
<PageTitle :title="pageTitle" :subtitle="pageSubtitle" />
|
||||||
</template>
|
</template>
|
||||||
<div>
|
|
||||||
Department stuff
|
<section class="department-overview" data-testid="superuser-department-overview">
|
||||||
<code>{{ departmentId }}</code>
|
<div class="overview-toolbar">
|
||||||
<code>{{ department.id }}</code>
|
<div class="overview-period">
|
||||||
<code>{{ department.name }}</code>
|
<label class="date-field">
|
||||||
<code>{{ department.description }}</code>
|
<span>{{ t("superuser_dashboard.department_overview.date_from") }}</span>
|
||||||
<code>{{ department.created_at }}</code>
|
<input
|
||||||
<code>{{ department.updated_at }}</code>
|
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>
|
||||||
|
<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>
|
</DepartmentSubPageWrapper>
|
||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.department-overview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
+55
-24
@@ -1,31 +1,60 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter} from "vue-router";
|
import { computed } from "vue";
|
||||||
import { ref } from 'vue';
|
import { useI18n } from "vue-i18n";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// Get the user from the route
|
const { t } = useI18n();
|
||||||
const departmentId = ref(parseInt(router.currentRoute.value.params.departmentId))
|
|
||||||
|
|
||||||
const tabs = [
|
const departmentId = computed(() => String(route.params.departmentId || ""));
|
||||||
{ name: 'Overblik', path: '/superuser/departments/' + departmentId.value },
|
const departmentPath = computed(() => `/superuser/departments/${encodeURIComponent(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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Get the current path
|
const tabs = computed(() => [
|
||||||
const currentPath = ref(router.currentRoute.value.path);
|
{
|
||||||
|
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 = computed(() => tabs.value.findIndex((tab) => tab.active(route.path)));
|
||||||
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
|
|
||||||
|
|
||||||
// Change the tab
|
|
||||||
const changeTab = (index) => {
|
const changeTab = (index) => {
|
||||||
router.push(tabs[index].path);
|
const tab = tabs.value[index];
|
||||||
|
if (tab) {
|
||||||
|
router.push(tab.path);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,13 +62,15 @@ const changeTab = (index) => {
|
|||||||
<div>
|
<div>
|
||||||
<div class="tabs is-right">
|
<div class="tabs is-right">
|
||||||
<ul>
|
<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>
|
<a>{{ tab.name }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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