Move backoffice department selector into title

This commit is contained in:
Jeppe Bundgaard
2026-07-06 19:27:05 +02:00
parent 27994d6ac6
commit dd8a0a1952
7 changed files with 252 additions and 56 deletions
+2 -2
View File
@@ -29,8 +29,8 @@ export const sourceMappings = [
projects: chromiumProjects,
},
{
name: "backoffice",
patterns: [/^src\/views\/backoffice\//u],
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\//u, /^src\/services\/limitedBackoffice\.js$/u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
+6
View File
@@ -442,6 +442,12 @@ export const router = createRouter({
},
{
name: 'limitedBackofficeEmployees',
path: '/backoffice/departments/:departmentId/employees',
component: LimitedBackofficeEmployees,
meta: { middleware: authMiddleware, titleKey: 'templates.limited_backoffice.employees.title' }
},
{
name: 'limitedBackofficeEmployeesLegacy',
path: '/backoffice/employees',
component: LimitedBackofficeEmployees,
meta: { middleware: authMiddleware, titleKey: 'templates.limited_backoffice.employees.title' }
@@ -1,6 +1,7 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import LimitedBackofficeLayout from "@/views/backoffice/components/LimitedBackofficeLayout.vue";
@@ -33,6 +34,8 @@ const loadQRCodeModule = () => {
};
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const departments = ref([]);
const roles = ref([]);
const employees = ref([]);
@@ -60,6 +63,19 @@ const form = ref({
department_ids: [],
});
const selectedDepartmentId = computed(() => {
const departmentId = Number.parseInt(String(route.params.departmentId ?? ""), 10);
return Number.isInteger(departmentId) && departmentId > 0 ? departmentId : null;
});
const selectedDepartment = computed(() =>
departments.value.find((department) => Number(department.id) === selectedDepartmentId.value) || null
);
const hasForbiddenDepartment = computed(
() => selectedDepartmentId.value !== null && departments.value.length > 0 && !selectedDepartment.value
);
const roleMessages = computed(() => ({
viewer: {
label: t("templates.limited_backoffice.roles.viewer.label"),
@@ -272,11 +288,23 @@ const sanitizePhone = (event) => {
const absoluteLoginLink = (loginPath) => new URL(String(loginPath || ""), window.location.origin).toString();
const visibleEmployees = computed(() => {
if (!selectedDepartmentId.value) {
return employees.value;
}
return employees.value.filter((employee) =>
(employee.departments || []).some((department) => Number(department.id) === selectedDepartmentId.value)
);
});
const loadEmployees = async () => {
const response = await getLimitedBackofficeEmployees({ includeInactive: includeInactive.value });
employees.value = unwrapLimitedBackofficeResponse(response) || [];
};
const defaultDepartmentIds = () => (selectedDepartment.value?.id ? [Number(selectedDepartment.value.id)] : []);
const loadData = async () => {
loading.value = true;
errorMessage.value = "";
@@ -291,6 +319,18 @@ const loadData = async () => {
if (!ROLE_KEYS.includes(form.value.role_key) || !roles.value.some((role) => role.key === form.value.role_key)) {
form.value.role_key = roles.value[0]?.key || "viewer";
}
const firstDepartment = departments.value[0] || null;
if (!selectedDepartmentId.value && firstDepartment?.id) {
await router.replace(`/backoffice/departments/${firstDepartment.id}/employees`);
}
resetForm();
if (hasForbiddenDepartment.value) {
employees.value = [];
return;
}
await loadEmployees();
} catch (error) {
errorMessage.value = limitedBackofficeErrorMessage(
@@ -312,7 +352,7 @@ const resetForm = () => {
phone: "",
password: "",
role_key: roles.value[0]?.key || "viewer",
department_ids: [],
department_ids: defaultDepartmentIds(),
};
};
@@ -479,7 +519,16 @@ const closeEmployeeLoginLink = () => {
loginLinkCopyMessage.value = "";
};
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/employees`);
};
const reloadEmployees = async () => {
if (hasForbiddenDepartment.value) {
employees.value = [];
return;
}
loading.value = true;
errorMessage.value = "";
try {
@@ -497,12 +546,36 @@ const reloadEmployees = async () => {
onMounted(() => {
void loadData();
});
watch(
() => selectedDepartmentId.value,
async (nextDepartmentId, previousDepartmentId) => {
if (nextDepartmentId === previousDepartmentId || loading.value || departments.value.length === 0) {
return;
}
resetForm();
await reloadEmployees();
}
);
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.hasPermission('limited_backoffice_access')">
<LimitedBackofficeLayout active-tab="employees" :departments="departments">
<div class="columns is-variable is-5">
<LimitedBackofficeLayout
active-tab="employees"
:departments="departments"
:selected-department-id="selectedDepartmentId"
:loading-departments="loading"
show-department-switcher
@change-department="changeDepartment"
>
<div v-if="hasForbiddenDepartment" class="notification is-danger is-light" data-testid="limited-employees-forbidden">
<strong>{{ t("templates.limited_backoffice.forbidden.title") }}</strong>
<p>{{ t("templates.limited_backoffice.forbidden.department") }}</p>
</div>
<div v-else class="columns is-variable is-5">
<div class="column is-5">
<form class="box limited-employee-form" data-testid="limited-employee-form" @submit.prevent="saveEmployee">
<h2 class="title is-5">
@@ -726,11 +799,15 @@ onMounted(() => {
{{ successMessage }}
</div>
<div v-if="!loading && employees.length === 0" class="notification is-light" data-testid="limited-employees-empty">
<div
v-if="!loading && visibleEmployees.length === 0"
class="notification is-light"
data-testid="limited-employees-empty"
>
{{ t("templates.limited_backoffice.employees.empty") }}
</div>
<div v-if="!loading && employees.length > 0" class="table-container">
<div v-if="!loading && visibleEmployees.length > 0" class="table-container">
<table class="table is-fullwidth is-hoverable limited-employees-table" data-testid="limited-employees-table">
<thead>
<tr>
@@ -743,7 +820,7 @@ onMounted(() => {
</tr>
</thead>
<tbody>
<tr v-for="employee in employees" :key="employee.id" :data-testid="`limited-employee-row-${employee.id}`">
<tr v-for="employee in visibleEmployees" :key="employee.id" :data-testid="`limited-employee-row-${employee.id}`">
<td>
<span class="tag is-light" :data-testid="`limited-employee-user-id-${employee.id}`">
{{ employee.user_id || employee.id }}
@@ -17,6 +17,10 @@ const departments = ref([]);
const loading = ref(false);
const errorMessage = ref("");
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
const loadDepartments = async () => {
loading.value = true;
errorMessage.value = "";
@@ -35,6 +35,12 @@ const pricesRoute = computed(() =>
: "/backoffice"
);
const employeesRoute = computed(() =>
props.selectedDepartmentId
? `/backoffice/departments/${encodeURIComponent(String(props.selectedDepartmentId))}/employees`
: "/backoffice/employees"
);
const tabs = computed(() => [
{
key: "prices",
@@ -44,7 +50,7 @@ const tabs = computed(() => [
{
key: "employees",
label: t("templates.limited_backoffice.nav.employees"),
to: "/backoffice/employees",
to: employeesRoute.value,
},
]);
@@ -63,7 +69,33 @@ const selectDepartment = (event) => {
<PageTitle
:title="t('templates.limited_backoffice.title')"
:subtitle="t('templates.limited_backoffice.subtitle')"
/>
>
<template #buttons>
<div v-if="props.showDepartmentSwitcher" class="field limited-backoffice__department-field">
<label class="label is-sr-only" for="limited-backoffice-department">
{{ t("templates.limited_backoffice.department") }}
</label>
<div class="control">
<div class="select is-small is-fullwidth">
<select
id="limited-backoffice-department"
:value="props.selectedDepartmentId || ''"
:disabled="props.loadingDepartments || props.departments.length === 0"
data-testid="limited-backoffice-department-select"
@change="selectDepartment"
>
<option value="" disabled>
{{ t("templates.limited_backoffice.select_department") }}
</option>
<option v-for="department in props.departments" :key="department.id" :value="department.id">
{{ department.name }}
</option>
</select>
</div>
</div>
</div>
</template>
</PageTitle>
<div class="limited-backoffice__toolbar">
<div class="tabs is-toggle is-small limited-backoffice__tabs" data-testid="limited-backoffice-tabs">
@@ -75,30 +107,6 @@ const selectDepartment = (event) => {
</li>
</ul>
</div>
<div v-if="props.showDepartmentSwitcher" class="field limited-backoffice__department-field">
<label class="label" for="limited-backoffice-department">
{{ t("templates.limited_backoffice.department") }}
</label>
<div class="control">
<div class="select is-fullwidth">
<select
id="limited-backoffice-department"
:value="props.selectedDepartmentId || ''"
:disabled="props.loadingDepartments || props.departments.length === 0"
data-testid="limited-backoffice-department-select"
@change="selectDepartment"
>
<option value="" disabled>
{{ t("templates.limited_backoffice.select_department") }}
</option>
<option v-for="department in props.departments" :key="department.id" :value="department.id">
{{ department.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<slot />
@@ -112,10 +120,8 @@ const selectDepartment = (event) => {
.limited-backoffice__toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
margin-bottom: 1.25rem;
}
@@ -124,7 +130,7 @@ const selectDepartment = (event) => {
}
.limited-backoffice__department-field {
min-width: min(100%, 260px);
min-width: min(42vw, 280px);
margin-bottom: 0;
}
@@ -138,11 +144,14 @@ const selectDepartment = (event) => {
align-items: stretch;
}
.limited-backoffice__tabs,
.limited-backoffice__department-field {
.limited-backoffice__tabs {
width: 100%;
}
.limited-backoffice__department-field {
min-width: 9rem;
}
.limited-backoffice__tabs :deep(ul) {
width: 100%;
}
+112 -13
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { API_HOST, seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
@@ -14,6 +14,7 @@ const limitedManagerPermissions = [
"limited_backoffice_prices_manage",
"limited_backoffice_employees_manage",
"department_access_1",
"department_access_2",
];
const sessionData = {
@@ -62,7 +63,10 @@ const adminLimitedSessionData = {
permissions: ["user", "admin", "limited_backoffice_access", "department_access_1"],
};
const assignedDepartments = [{ id: 1, name: "Assigned Depot", description: "", visible: true, archived: false }];
const assignedDepartments = [
{ id: 1, name: "Assigned Depot", description: "", visible: true, archived: false },
{ id: 2, name: "Remote Depot", description: "", visible: true, archived: false },
];
const pricePayload = {
department: { id: 1, name: "Assigned Depot", description: "" },
@@ -235,6 +239,11 @@ const rolePermissionGroups = {
],
};
const remotePricePayload = {
...pricePayload,
department: { id: 2, name: "Remote Depot", description: "" },
};
const rolesPayload = [
{ key: "viewer", label: "Viewer", description: "Can view.", permission_groups: rolePermissionGroups.viewer },
{ key: "cashier", label: "Cashier", description: "Can sell.", permission_groups: rolePermissionGroups.cashier },
@@ -274,6 +283,17 @@ const employeesPayload = [
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
},
{
id: 502,
customer_number: 900000502,
display_name: "Riley Remote",
email: "riley@example.com",
active: true,
role: { key: "viewer", label: "Viewer", description: "Can view." },
departments: [{ id: 2, name: "Remote Depot" }],
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
},
];
async function seedLimitedBackofficeSession(page, token = "limited-backoffice-token") {
@@ -350,6 +370,11 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
return;
}
if (pathname.endsWith("/limited-backoffice/departments/2/prices") && method === "GET") {
await route.fulfill(json({ data: remotePricePayload }));
return;
}
if (pathname.endsWith("/limited-backoffice/departments/1/prices") && method === "PUT") {
const body = request.postDataJSON?.() || null;
priceUpdateCalls.push(body);
@@ -462,6 +487,20 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
};
}
async function expectDepartmentSelectorInTitleRow(page: Page) {
const headingBox = await page.getByRole("heading", { name: "Backoffice" }).boundingBox();
const selectBox = await page.getByTestId("limited-backoffice-department-select").boundingBox();
expect(headingBox).not.toBeNull();
expect(selectBox).not.toBeNull();
if (!headingBox || !selectBox) {
return;
}
expect(selectBox.x).toBeGreaterThan(headingBox.x);
expect(Math.abs(selectBox.y + selectBox.height / 2 - (headingBox.y + headingBox.height / 2))).toBeLessThan(48);
}
test.describe("Limited backoffice", () => {
test("shows the limited backoffice header shortcut only on department-scoped admin pages", async ({
page,
@@ -539,7 +578,9 @@ test.describe("Limited backoffice", () => {
await page.goto("/backoffice/departments/1/prices");
await expect(page.getByTestId("limited-prices-title")).toBeVisible();
await expectDepartmentSelectorInTitleRow(page);
await expect(page.getByTestId("limited-backoffice-department-select")).toContainText("Assigned Depot");
await expect(page.getByTestId("limited-backoffice-department-select")).toContainText("Remote Depot");
await expect(page.getByTestId("limited-backoffice-department-select")).not.toContainText("Other Depot");
await expect(page.getByTestId("limited-price-row-101")).toContainText("Truck wash");
await expect(page.getByTestId("limited-price-input-101")).toHaveValue("125");
@@ -718,12 +759,16 @@ test.describe("Limited backoffice", () => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await page.goto("/backoffice/departments/1/employees");
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expectDepartmentSelectorInTitleRow(page);
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Clerk");
await expect(page.getByTestId("limited-employee-user-id-501")).toHaveText("501");
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+45 12345678");
await expect(page.getByTestId("limited-employee-row-502")).toHaveCount(0);
await expect(page.getByTestId("limited-employee-department-1")).toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).not.toBeChecked();
await expect(page.locator("#limited-employee-role option")).toHaveCount(5);
await expect(page.getByTestId("limited-employee-role")).not.toContainText("Superuser");
await expect(page.locator("body")).not.toContainText("department_access_1");
@@ -834,15 +879,14 @@ test.describe("Limited backoffice", () => {
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
await expect(page.getByTestId("limited-employee-save")).toHaveClass(/is-fullwidth/);
await expect(page.getByTestId("limited-employee-departments").locator(".switch")).toHaveCount(1);
await expect(page.getByTestId("limited-employee-departments").locator(".switch")).toHaveCount(2);
await expect(page.getByTestId("limited-employee-department-1")).toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).not.toBeChecked();
await expect(page.getByTestId("limited-employee-phone-country-code")).toContainText("+45");
await page.getByTestId("limited-employee-name").fill("No Phone Worker");
await page.getByTestId("limited-employee-email").fill("no-phone@example.com");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
await page.getByTestId("limited-employee-department-1").click();
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
@@ -856,14 +900,13 @@ test.describe("Limited backoffice", () => {
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-902")).toContainText("No Phone Worker");
await expect(page.getByTestId("limited-employee-row-903")).toContainText("No Phone Worker");
await page.getByTestId("limited-employee-name").fill("Phone Worker");
await page.getByTestId("limited-employee-email").fill("phone@example.com");
await page.getByTestId("limited-employee-phone-country-code").selectOption("358");
await page.getByTestId("limited-employee-phone").fill("87654321");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await page.getByTestId("limited-employee-department-1").click();
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
@@ -877,8 +920,8 @@ test.describe("Limited backoffice", () => {
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-903")).toContainText("Phone Worker");
await expect(page.getByTestId("limited-employee-phone-903")).toHaveText("+358 87654321");
await expect(page.getByTestId("limited-employee-row-904")).toContainText("Phone Worker");
await expect(page.getByTestId("limited-employee-phone-904")).toHaveText("+358 87654321");
});
test("edits employee contact details without requiring a new password", async ({ page }) => {
@@ -912,19 +955,75 @@ test.describe("Limited backoffice", () => {
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+358 87654321");
});
test("keeps department context across employee access navigation", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await expect(page).toHaveURL(/\/backoffice\/departments\/1\/employees$/);
await expect(page.getByTestId("limited-backoffice-tab-prices")).toHaveAttribute(
"href",
"/backoffice/departments/1/prices"
);
await expect(page.getByTestId("limited-backoffice-tab-employees")).toHaveAttribute(
"href",
"/backoffice/departments/1/employees"
);
await page.getByTestId("limited-backoffice-department-select").selectOption("2");
await expect(page).toHaveURL(/\/backoffice\/departments\/2\/employees$/);
await expect(page.getByTestId("limited-employee-row-501")).toHaveCount(0);
await expect(page.getByTestId("limited-employee-row-502")).toContainText("Riley Remote");
await expect(page.getByTestId("limited-employee-department-1")).not.toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).toBeChecked();
await expect(page.getByTestId("limited-backoffice-tab-prices")).toHaveAttribute(
"href",
"/backoffice/departments/2/prices"
);
await page.getByTestId("limited-backoffice-tab-prices").click();
await expect(page).toHaveURL(/\/backoffice\/departments\/2\/prices$/);
await expect(page.getByTestId("limited-backoffice-tab-employees")).toHaveAttribute(
"href",
"/backoffice/departments/2/employees"
);
expect(api.forbiddenCalls).toEqual([]);
});
test("does not render data for a department outside the manager scope", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/2/prices");
await page.goto("/backoffice/departments/3/prices");
await expect(page.getByTestId("limited-prices-forbidden")).toBeVisible();
await expect(page.locator("body")).not.toContainText("Secret Depot");
await expect(page.getByTestId("limited-prices-table")).toHaveCount(0);
expect(api.calls.some((call) => call.includes("/limited-backoffice/departments/2/prices"))).toBe(false);
expect(api.calls.some((call) => call.includes("/limited-backoffice/departments/3/prices"))).toBe(false);
expect(api.forbiddenCalls).toEqual([]);
});
test("does not render employee data for a department outside the manager scope", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/3/employees");
await expect(page.getByTestId("limited-employees-forbidden")).toBeVisible();
await expect(page.locator("body")).not.toContainText("Secret Depot");
await expect(page.getByTestId("limited-employees-table")).toHaveCount(0);
expect(api.calls.some((call) => call.includes("/limited-backoffice/employees"))).toBe(false);
expect(api.forbiddenCalls).toEqual([]);
});
});
+2 -1
View File
@@ -39,13 +39,14 @@ describe("Playwright PR mapping", () => {
).toContain("tests/e2e/admin-department-notifications.spec.ts");
});
it("maps limited backoffice view changes to limited backoffice E2E coverage", () => {
it("maps limited backoffice view and service changes to limited backoffice E2E coverage", () => {
expect(specsFor("src/views/backoffice/LimitedBackofficePrices.vue")).toContain(
"tests/e2e/limited-backoffice.spec.ts"
);
expect(specsFor("src/views/backoffice/components/LimitedBackofficeLayout.vue")).toContain(
"tests/e2e/limited-backoffice.spec.ts"
);
expect(specsFor("src/services/limitedBackoffice.js")).toContain("tests/e2e/limited-backoffice.spec.ts");
});
it("maps superuser role permission page changes to role permissions E2E coverage", () => {