diff --git a/scripts/run-playwright-full-slice.mjs b/scripts/run-playwright-full-slice.mjs index 023ef7e0..8531b22d 100644 --- a/scripts/run-playwright-full-slice.mjs +++ b/scripts/run-playwright-full-slice.mjs @@ -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", diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index c9bb93c9..91e14c73 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -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'}", diff --git a/src/i18n/source/global/shared/superuser_dashboard/index.json b/src/i18n/source/global/shared/superuser_dashboard/index.json index 8c33a175..a2059492 100644 --- a/src/i18n/source/global/shared/superuser_dashboard/index.json +++ b/src/i18n/source/global/shared/superuser_dashboard/index.json @@ -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'}", diff --git a/src/services/superuserDepartmentOverview.js b/src/services/superuserDepartmentOverview.js new file mode 100644 index 00000000..b48c8d58 --- /dev/null +++ b/src/services/superuserDepartmentOverview.js @@ -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 + ); +}; diff --git a/src/views/dashboards/superUserDashboard/department/Department.vue b/src/views/dashboards/superUserDashboard/department/Department.vue index a4c6536a..eff54017 100644 --- a/src/views/dashboards/superUserDashboard/department/Department.vue +++ b/src/views/dashboards/superUserDashboard/department/Department.vue @@ -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 } +); \ No newline at end of file +.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; + } +} + diff --git a/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue b/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue index a5699627..5e0c4da1 100644 --- a/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue +++ b/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue @@ -1,31 +1,60 @@ @@ -33,13 +62,15 @@ const changeTab = (index) => {
- - diff --git a/tests/e2e/superuser-department-overview.spec.js b/tests/e2e/superuser-department-overview.spec.js new file mode 100644 index 00000000..bb7be94a --- /dev/null +++ b/tests/e2e/superuser-department-overview.spec.js @@ -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"); + }); +}); diff --git a/tests/unit/superuser-department-overview.spec.js b/tests/unit/superuser-department-overview.spec.js new file mode 100644 index 00000000..49ef4ff8 --- /dev/null +++ b/tests/unit/superuser-department-overview.spec.js @@ -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: "

{{ title }}

{{ subtitle }}

", + }, +})); + +vi.mock("@/components/page/wrappers/RestrictedPageWrapper.vue", () => ({ + default: { + template: "
", + }, +})); + +vi.mock("@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue", () => ({ + default: { + template: "
", + }, +})); + +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"); + }); +});