Compare commits

...
Author SHA1 Message Date
openhands c11b164188 fix(TRU-89): customer portal - 'tilladelser' (permissions) tab
Adds a new permissions tab in the kundeportal that exposes the two
primary role templates (driver / booking_coordinator) and the five
permission groups (vehicles, selfserve, bookings, orders, driver
management) so the customer can pick a default driver access template
for new drivers created via the QR flow (TRU-88 / TRU-90).
2026-08-17 18:01:47 +00:00
openhands 882bc64fa6 fix(path-editor): filter machine options to only show available machine (TRU-91)
The path editor previously rendered all 12 program buttons and all 12 program
wheel positions regardless of whether the current lane had a configured,
non-deleted machine type. This made it possible to author paths for programs
that the machine does not actually support.

Filter pathEditorMachineButtonOptions and pathEditorProgramButtonOptions
based on whether the current lane resolves to an available selfserve_machine_types
row. When the machine is not available, the machine button grid is empty and
the program wheel only exposes the OFF position.
2026-08-17 16:17:04 +00:00
7 changed files with 462 additions and 12 deletions
@@ -60,6 +60,16 @@ const menu_items = computed(() => [
'user' 'user'
] ]
}, },
{
name: t('user_menu.my_permissions'),
route: '/user/permissions',
icon: 'fas fa-user-shield',
children: [],
permissions: [
'user',
'SUBUSERS_LIST'
]
},
]); ]);
</script> </script>
@@ -53,6 +53,30 @@
"menu": { "menu": {
"statistics": "@:{'phrases.compat.global.statistics'}" "statistics": "@:{'phrases.compat.global.statistics'}"
}, },
"permissions": {
"card_chauffor_label": "Chauffør (standard)",
"card_chauffor_description": "Kan bruge døgnvask, oprette bookinger, se køretøjer og ordrer.",
"card_booking_label": "Bookingkoodinator",
"card_booking_description": "Kan oprette og redigere bookinger samt se køretøjer og ordrer.",
"card_intro": "Vælg den rolle, som nye chauffører som standard skal have, når de oprettes via QR-kode.",
"groups": {
"vehicles": "Køretøjer",
"selfserve": "Døgnvask",
"bookings": "Bookinger",
"orders": "Ordrer",
"driver_management": "Chaufførstyring"
},
"loading": "Henter tilladelser...",
"save": "Gem standardrolle",
"saved": "Standardrollen er gemt.",
"save_error": "Standardrollen kunne ikke gemmes.",
"selected_template": "Valgt rolle: {label}",
"subtitle": "Vælg standardtilladelser for nye chauffører.",
"toggle_booking_coordinator": "Bookingkoodinator",
"toggle_chauffor": "Chauffør",
"toggle_description": "De 5 kategorier nedenfor viser, hvad rollen giver adgang til.",
"title": "Mine tilladelser"
},
"orders": { "orders": {
"single_subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.detaljer'} @:{'terms.glossary.om'} transaktionen", "single_subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.detaljer'} @:{'terms.glossary.om'} transaktionen",
"subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}", "subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}",
@@ -3,6 +3,7 @@
"user_menu": { "user_menu": {
"my_bookings": "@:{'terms.glossary.mine'} @:{'terms.glossary.bookinger'}", "my_bookings": "@:{'terms.glossary.mine'} @:{'terms.glossary.bookinger'}",
"my_invoices": "@:{'terms.glossary.mine'} @:{'terms.glossary.fakturaer'}", "my_invoices": "@:{'terms.glossary.mine'} @:{'terms.glossary.fakturaer'}",
"my_permissions": "@:{'terms.glossary.mine'} @:{'terms.glossary.tilladelser'}",
"my_vehicles": "@:{'terms.glossary.mine'} @:{'terms.glossary.køretøjer'}", "my_vehicles": "@:{'terms.glossary.mine'} @:{'terms.glossary.køretøjer'}",
"my_washes": "@:{'terms.glossary.mine'} @:{'terms.glossary.vaske'}" "my_washes": "@:{'terms.glossary.mine'} @:{'terms.glossary.vaske'}"
} }
+7
View File
@@ -146,6 +146,7 @@ const DepartmentTimeBookingsTypes = lazyView('@/views/dashboards/departmentDashb
const DepartmentModulesSetup = lazyView('@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue'); const DepartmentModulesSetup = lazyView('@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue');
const Vehicle = lazyView('@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue'); const Vehicle = lazyView('@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue');
const MyInvoices = lazyView('@/views/dashboards/userDashboard/invoices/MyInvoices.vue'); const MyInvoices = lazyView('@/views/dashboards/userDashboard/invoices/MyInvoices.vue');
const MyPermissions = lazyView('@/views/dashboards/userDashboard/permissions/MyPermissions.vue');
const MicrosoftCallbackToken = lazyView('@/views/callback/microsoft/MicrosoftCallbackToken.vue'); const MicrosoftCallbackToken = lazyView('@/views/callback/microsoft/MicrosoftCallbackToken.vue');
const ConfigurationEntra = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue'); const ConfigurationEntra = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue');
const DepartmentTimeBookingsNew = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue'); const DepartmentTimeBookingsNew = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue');
@@ -369,6 +370,12 @@ export const router = createRouter({
component: MyInvoices, component: MyInvoices,
meta: { middleware: authMiddleware } meta: { middleware: authMiddleware }
}, },
{
name: 'mypermissions',
path: '/user/permissions',
component: MyPermissions,
meta: { middleware: authMiddleware }
},
{ {
name: 'myorder', name: 'myorder',
path: '/user/orders/:orderId', path: '/user/orders/:orderId',
@@ -1264,14 +1264,19 @@ const createPathEditorQuestion = async () => {
await loadPathOutcomes({ force: true }); await loadPathOutcomes({ force: true });
}; };
const pathEditorProgramButtonOptions = computed(() => [ const pathEditorProgramButtonOptions = computed(() => {
{ id: 0, value: "0", label: "0 = OFF" }, if (!pathEditorMachineIsAvailable.value) {
...Array.from({ length: 12 }, (_, index) => ({ return [{ id: 0, value: "0", label: "0 = OFF" }];
id: index + 1, }
value: String(index + 1), return [
label: `Wheel position #${index + 1}`, { id: 0, value: "0", label: "0 = OFF" },
})), ...Array.from({ length: 12 }, (_, index) => ({
]); id: index + 1,
value: String(index + 1),
label: `Wheel position #${index + 1}`,
})),
];
});
@@ -1392,16 +1397,56 @@ const pathVerificationSelectedCaseHasRun = computed(() =>
) )
); );
const pathEditorMachineButtonOptions = computed(() => // TRU-91: Filter path editor machine options to only show enabled/available
Array.from({ length: 12 }, (_, index) => { // machine programs for the current scope. A machine is considered "available"
// when the current lane has a machine_type_id that resolves to a non-deleted
// selfserve_machine_types row in the lookups.
const pathEditorCurrentLaneRow = computed(() => {
const laneId = parseIntOrZero(selectedSimulatorLaneId.value);
if (laneId <= 0) {
return null;
}
return laneRowById(laneId);
});
const pathEditorCurrentMachineType = computed(() => {
const lane = pathEditorCurrentLaneRow.value;
if (!lane) {
return null;
}
const machineTypeId = parseNullableInt(lane.machine_type_id);
if (!machineTypeId) {
return null;
}
const machineTypeRow = lookupRowById("machine_types", machineTypeId);
if (!machineTypeRow) {
return null;
}
if (
machineTypeRow.deleted_at !== null &&
machineTypeRow.deleted_at !== undefined &&
machineTypeRow.deleted_at !== ""
) {
return null;
}
return machineTypeRow;
});
const pathEditorMachineIsAvailable = computed(() => Boolean(pathEditorCurrentMachineType.value));
const pathEditorMachineButtonOptions = computed(() => {
if (!pathEditorMachineIsAvailable.value) {
return [];
}
return Array.from({ length: 12 }, (_, index) => {
const option = taskButtonOptions.find((entry) => entry.id === index); const option = taskButtonOptions.find((entry) => entry.id === index);
return { return {
id: index, id: index,
label: option?.description || `Machine button ${index}`, label: option?.description || `Machine button ${index}`,
description: option?.name || `Program ${index + 1}`, description: option?.name || `Program ${index + 1}`,
}; };
}) });
); });
const pathEditorTaskTitleKeySlug = (key) => String(key || "").replace(/[^a-zA-Z0-9_-]/g, "-"); const pathEditorTaskTitleKeySlug = (key) => String(key || "").replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -0,0 +1,330 @@
<script setup>
import { onMounted, ref, computed } from "vue";
import Swal from "sweetalert2";
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
/**
* Permission groups (5 toggles) that mirror the existing 5 capability groups
* in modules/subusers/classes/subuser_permission_templates_service.php.
* They cover the 5 main areas a customer wants to grant their drivers:
* - vehicles (Køretøjer)
* - selfserve (Døgnvask)
* - bookings (Bookinger)
* - orders (Ordrer)
* - driver_management (Chaufførstyring)
*/
const PERMISSION_GROUPS = [
{ key: "vehicles", capabilities: ["VEHICLES_LIST", "VEHICLES_EDIT", "VEHICLES_DELETE", "VEHICLES_ADD"] },
{ key: "selfserve", capabilities: ["SELFSERVE_LIST", "SELFSERVE_EDIT", "SELFSERVE_DELETE", "SELFSERVE_ADD"] },
{ key: "bookings", capabilities: ["BOOKINGS_LIST", "BOOKINGS_EDIT", "BOOKINGS_DELETE", "BOOKINGS_ADD"] },
{ key: "orders", capabilities: ["ORDERS_LIST", "ORDERS_EDIT"] },
{ key: "driver_management", capabilities: ["SUBUSERS_LIST", "SUBUSERS_EDIT", "SUBUSERS_DELETE", "SUBUSERS_ADD"] },
];
/**
* The two primary roles (templates) shown to the customer in the kunde portal
* "tilladelser" tab. The customer picks one as the default for new drivers
* created through the QR flow (see TRU-88 / TRU-90).
*
* - chauffeur (driver) : standard self-serve + bookings
* - booking_coordinator : extended bookings/orders (no self-serve)
*/
const PRIMARY_ROLE_KEYS = ["driver", "booking_coordinator"];
const isLoading = ref(true);
const isSaving = ref(false);
const accessModel = ref({ templates: [], groups: [] });
const selectedTemplateKey = ref(null);
const selectedTemplate = computed(() => {
if (!selectedTemplateKey.value) {
return null;
}
return accessModel.value.templates.find((template) => template.key === selectedTemplateKey.value) || null;
});
const isGroupActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
return groups.some((group) => group.key === groupKey);
};
const isGroupFullyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
const group = groups.find((g) => g.key === groupKey);
if (!group) {
return false;
}
// "Fully active" means all 4 capabilities in the group are granted
const groupDefinition = PERMISSION_GROUPS.find((g) => g.key === groupKey);
if (!groupDefinition) {
return false;
}
return groupDefinition.capabilities.every((capability) => group.capabilities.includes(capability));
};
const isGroupPartiallyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
return isGroupActive(groupKey) && !isGroupFullyActive(groupKey);
};
const isPrimaryRole = (key) => PRIMARY_ROLE_KEYS.includes(key);
const loadAccessModel = async () => {
isLoading.value = true;
try {
const model = await SessionUser.objects.subuser_grants.functions.getPermissionTemplates();
const payload = model?.data?.data || model?.data || model;
accessModel.value = {
templates: Array.isArray(payload?.templates) ? payload.templates : [],
groups: Array.isArray(payload?.groups) ? payload.groups : [],
};
// Read the customer's stored default template via SessionUser / API
const stored = await SessionUser.request("/customer/default-driver-template", "GET")
.then((response) => response?.data?.data?.template_key || response?.data?.template_key || null)
.catch(() => null);
if (stored && accessModel.value.templates.some((template) => template.key === stored)) {
selectedTemplateKey.value = stored;
} else {
const chauffeur = accessModel.value.templates.find((template) => template.key === "driver");
selectedTemplateKey.value = chauffeur?.key || accessModel.value.templates[0]?.key || null;
}
} catch (error) {
console.error("Failed to load permission templates:", error);
accessModel.value = { templates: [], groups: [] };
await Swal.fire({
title: t("common.error"),
text: error?.message || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isLoading.value = false;
}
};
const onSelectTemplate = (key) => {
if (!accessModel.value.templates.some((template) => template.key === key)) {
return;
}
selectedTemplateKey.value = key;
};
const onSaveDefault = async () => {
if (!selectedTemplateKey.value || isSaving.value) {
return;
}
isSaving.value = true;
try {
await SessionUser.request("/customer/default-driver-template", "PUT", {
template_key: selectedTemplateKey.value,
});
await Swal.fire({
title: t("user_dashboard.permissions.saved"),
icon: "success",
timer: 1500,
showConfirmButton: false,
});
} catch (error) {
await Swal.fire({
title: t("common.error"),
text: SessionUser.functions.parseErrorMessage(error) || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isSaving.value = false;
}
};
onMounted(loadAccessModel);
</script>
<template>
<UserDashboardPageWrapper
:title="$t('user_dashboard.permissions.title')"
:subtitle="$t('user_dashboard.permissions.subtitle')"
>
<div v-if="isLoading" class="notification is-light">
{{ $t("user_dashboard.permissions.loading") }}
</div>
<template v-else>
<div class="permissions-tab" data-testid="user-permissions-tab">
<p class="block has-text-grey">
{{ $t("user_dashboard.permissions.card_intro") }}
</p>
<div class="columns is-multiline" data-testid="user-permissions-roles">
<div
v-for="template in accessModel.templates.filter((tpl) => isPrimaryRole(tpl.key))"
:key="template.key"
class="column is-half"
>
<button
type="button"
class="permission-role-card"
:class="{ 'permission-role-card--selected': selectedTemplateKey === template.key }"
:data-testid="`user-permission-role-${template.key}`"
@click="onSelectTemplate(template.key)"
>
<span class="permission-role-card__title">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_label")
: $t("user_dashboard.permissions.card_booking_label")
}}
</span>
<span class="permission-role-card__description">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_description")
: $t("user_dashboard.permissions.card_booking_description")
}}
</span>
<span
v-if="selectedTemplateKey === template.key"
class="tag is-success is-light mt-2"
:data-testid="`user-permission-role-selected-${template.key}`"
>
{{ $t("user_dashboard.permissions.selected_template", { label: template.label }) }}
</span>
</button>
</div>
</div>
<div class="permissions-tab__toggles" data-testid="user-permissions-toggles">
<h3 class="title is-6 mb-2">
<span class="icon"><i class="fas fa-sliders-h"></i></span>
<span>{{ $t("user_dashboard.permissions.toggle_description") }}</span>
</h3>
<div class="columns is-multiline">
<div
v-for="group in PERMISSION_GROUPS"
:key="group.key"
class="column is-half"
>
<div
class="permission-toggle box"
:class="{
'permission-toggle--active': isGroupFullyActive(group.key),
'permission-toggle--partial': isGroupPartiallyActive(group.key),
}"
:data-testid="`user-permission-toggle-${group.key}`"
>
<div class="level is-mobile mb-0">
<div class="level-left">
<div class="level-item">
<div>
<p class="permission-toggle__label">
{{ $t(`user_dashboard.permissions.groups.${group.key}`) }}
</p>
<p class="help has-text-grey">
{{ group.capabilities.length }} rettigheder
</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<b-switch
:model-value="isGroupFullyActive(group.key) || isGroupPartiallyActive(group.key)"
disabled
size="is-small"
type="is-success"
:data-testid="`user-permission-toggle-switch-${group.key}`"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="permissions-tab__actions mt-4">
<button
class="button is-link"
:class="{ 'is-loading': isSaving }"
:disabled="!selectedTemplateKey || isSaving"
data-testid="user-permissions-save"
@click="onSaveDefault"
>
<span class="icon"><i class="fas fa-save"></i></span>
<span>{{ $t("user_dashboard.permissions.save") }}</span>
</button>
</div>
</div>
</template>
</UserDashboardPageWrapper>
</template>
<style scoped>
.permissions-tab .permission-role-card {
align-items: flex-start;
background: #fff;
border: 1px solid #dbdbdb;
border-radius: 6px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 150px;
padding: 1.25rem;
text-align: left;
width: 100%;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.permissions-tab .permission-role-card:hover,
.permissions-tab .permission-role-card--selected {
border-color: #3273dc;
box-shadow: 0 0 0 1px #3273dc;
}
.permissions-tab .permission-role-card__title {
color: #1f2933;
font-weight: 700;
font-size: 1.1rem;
}
.permissions-tab .permission-role-card__description {
color: #4a4a4a;
font-size: 0.9rem;
line-height: 1.4;
}
.permissions-tab .permission-toggle {
border: 1px solid #e6ebf1;
border-radius: 6px;
padding: 0.85rem 1rem;
background: #fafbfc;
}
.permissions-tab .permission-toggle--active {
background: #f1faf3;
border-color: #48c78e;
}
.permissions-tab .permission-toggle--partial {
background: #fff8e1;
border-color: #ffe08a;
}
.permissions-tab .permission-toggle__label {
font-weight: 600;
color: #1f2933;
margin: 0;
}
</style>
@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio path editor machine filter (TRU-91)", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("defines a path editor machine-availability check tied to the current lane and machine type", () => {
expect(studioSource).toContain("pathEditorCurrentLaneRow");
expect(studioSource).toContain("pathEditorCurrentMachineType");
expect(studioSource).toContain("pathEditorMachineIsAvailable");
});
it("returns an empty machine button list when the machine is not available", () => {
expect(studioSource).toMatch(
/pathEditorMachineButtonOptions[\s\S]{0,400}if\s*\(\s*!pathEditorMachineIsAvailable\.value\s*\)\s*\{\s*return\s*\[\s*\]\s*;\s*\}/
);
});
it("limits program wheel options to the OFF position when the machine is not available", () => {
expect(studioSource).toMatch(
/pathEditorProgramButtonOptions[\s\S]{0,600}if\s*\(\s*!pathEditorMachineIsAvailable\.value\s*\)[\s\S]{0,200}return\s*\[\s*\{\s*id:\s*0,\s*value:\s*"0",\s*label:\s*"0 = OFF"\s*\}\s*\]/
);
});
it("considers a machine type deleted when its deleted_at is set", () => {
expect(studioSource).toMatch(/machineTypeRow\.deleted_at[\s\S]{0,80}return\s+null/);
});
});