Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ce3fbb56 | ||
|
|
61a27a0e8e | ||
|
|
4637cb867b | ||
|
|
3873849de1 | ||
|
|
f22ca71558 |
@@ -38,7 +38,7 @@
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
|
||||
@@ -328,6 +328,47 @@ const getCustomerDetailItems = (scan) => {
|
||||
return items;
|
||||
};
|
||||
|
||||
const getLastWashDetailItem = (scan) => {
|
||||
// TRU-78 / DRIFT 17: surface the "last washed" timestamp for a scanned
|
||||
// license plate on the landing page so the operator can see at a glance
|
||||
// when a trailer was last washed (DHL pick-up use case).
|
||||
const lastWash = formatLastWashTimestamp(scan?.last_wash);
|
||||
if (!lastWash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: SessionUser.objects.global.language.last_wash || t('admin.pos.recent_washes'),
|
||||
value: lastWash,
|
||||
testKey: 'last-wash',
|
||||
};
|
||||
};
|
||||
|
||||
const getLastWashEmptyStateText = () => {
|
||||
return t('admin.pos.never_washed');
|
||||
};
|
||||
|
||||
const formatLastWashTimestamp = (value) => {
|
||||
if (!isMeaningfulValue(value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsedDate = new Date(String(value).replace(' ', 'T'));
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale.value || undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(parsedDate);
|
||||
} catch (error) {
|
||||
console.error('Unable to format last_wash timestamp', error);
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const getVehicleDetailItems = (scan) => {
|
||||
const detailState = getPlateDetailState(scan.plate);
|
||||
if (detailState.status !== 'loaded') {
|
||||
@@ -493,19 +534,29 @@ setTimeout(() => {
|
||||
:data-testid="`pos-recent-scan-details-${scan.id}`"
|
||||
>
|
||||
<div
|
||||
v-if="getCustomerDetailItems(scan).length > 0"
|
||||
v-if="getCustomerDetailItems(scan).length > 0 || getLastWashDetailItem(scan)"
|
||||
class="pos-scan-details__grid pos-scan-details__grid--customer"
|
||||
>
|
||||
<div
|
||||
v-for="item in getCustomerDetailItems(scan)"
|
||||
v-for="item in [...getCustomerDetailItems(scan), getLastWashDetailItem(scan)].filter(Boolean)"
|
||||
:key="item.label"
|
||||
class="pos-scan-detail-field"
|
||||
:data-testid="`pos-recent-scan-detail-${scan.id}-${item.testKey || item.label}`"
|
||||
>
|
||||
<span class="pos-scan-detail-field__label">{{ item.label }}</span>
|
||||
<span class="pos-scan-detail-field__value">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!getLastWashDetailItem(scan) && scan.last_wash == null"
|
||||
class="pos-scan-details__state"
|
||||
:data-testid="`pos-recent-scan-never-washed-${scan.id}`"
|
||||
>
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
<span>{{ getLastWashEmptyStateText() }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="getPlateDetailState(scan.plate).status === 'loading'" class="pos-scan-details__state">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>{{ $t('admin.pos.recent_scan_details_loading') }}</span>
|
||||
|
||||
@@ -60,16 +60,6 @@ const menu_items = computed(() => [
|
||||
'user'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: t('user_menu.my_permissions'),
|
||||
route: '/user/permissions',
|
||||
icon: 'fas fa-user-shield',
|
||||
children: [],
|
||||
permissions: [
|
||||
'user',
|
||||
'SUBUSERS_LIST'
|
||||
]
|
||||
},
|
||||
]);
|
||||
|
||||
</script>
|
||||
|
||||
@@ -5448,6 +5448,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.ydelse'}, @:{'words.generated.tilvalg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.sidste'} @:{'words.generated.vask'}",
|
||||
"manual_entry": "Manuell @:{'words.generated.registrering'}",
|
||||
"never_washed": "Aldrig vasket",
|
||||
"new_order": "@.capitalize:{'words.generated.ny'} order",
|
||||
"new_sale": "@.capitalize:{'words.generated.nyt'} @:{'words.generated.salg'}",
|
||||
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bookinger'} @:{'words.generated.fundet'}",
|
||||
|
||||
@@ -5558,6 +5558,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.gleiche'} @:{'words.generated.leistung'}, @:{'words.generated.zusatzoptionen'} @:{'words.generated.und'} @:{'words.generated.zusatzartikel'} @:{'words.generated.wie'} @:{'words.generated.bei'} @:{'words.generated.der'} @:{'words.generated.letzten'} @:{'words.generated.wasche'}",
|
||||
"manual_entry": "@.capitalize:{'words.generated.manuell'} registrering",
|
||||
"never_washed": "Noch nie gewaschen",
|
||||
"new_order": "@.capitalize:{'words.generated.neuer'} @:{'words.generated.auftrag'}",
|
||||
"new_sale": "Nytt salg",
|
||||
"no_bookings": "@.capitalize:{'words.generated.keine'} @:{'words.generated.buchungen'} @:{'words.generated.gefunden'}",
|
||||
|
||||
@@ -5279,6 +5279,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.same'} @:{'words.generated.service'}, @:{'words.generated.add'}-@:{'words.generated.ons'}, @:{'words.generated.and'} @:{'words.generated.extra'} @:{'words.generated.items'} @:{'words.generated.as'} @:{'words.replication.article.host_mention'} @:{'words.generated.previous'} @:{'words.generated.wash'}",
|
||||
"manual_entry": "@:{'words.generated.manual'} @:{'words.generated.registration'}",
|
||||
"never_washed": "Never washed",
|
||||
"new_order": "@.capitalize:{'words.generated.new'} @:{'words.generated.order'}",
|
||||
"new_sale": "@.capitalize:{'words.generated.new'} sale",
|
||||
"no_bookings": "@.capitalize:{'words.generated.no'} @:{'words.generated.orders'} @:{'words.generated.found'}",
|
||||
|
||||
@@ -4852,6 +4852,8 @@
|
||||
"last_wash_matches_current_selection": "@:{'templates.generated.compat.pos.last_wash_matches_current_selection'}",
|
||||
"license_plate": "@:{'templates.generated.compat.tables.common.registration_number'}",
|
||||
"manual_entry": "@:{'templates.generated.compat.pos.manual_entry'}",
|
||||
"last_wash": "@:{'templates.generated.compat.global.last_wash'}",
|
||||
"never_washed": "@:{'templates.generated.compat.pos.never_washed'}",
|
||||
"new_order": "@:{'templates.generated.compat.pos.new_order'}",
|
||||
"new_sale": "@:{'templates.generated.compat.pos.new_sale'}",
|
||||
"no_bookings": "@:{'templates.generated.compat.pos.no_bookings'}",
|
||||
|
||||
@@ -5561,6 +5561,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.tjeneste'}, @:{'words.generated.tillegg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.forrige'} @:{'words.generated.vask'}",
|
||||
"manual_entry": "Manuell @:{'words.generated.registrering'}",
|
||||
"never_washed": "Aldri vasket",
|
||||
"new_order": "@.capitalize:{'words.generated.bestillingen'}",
|
||||
"new_sale": "@.capitalize:{'words.generated.nytt'} @:{'words.generated.salg'}",
|
||||
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
|
||||
|
||||
@@ -5611,6 +5611,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samma'} @:{'words.generated.tjanst'}, @:{'words.generated.tillval'} @:{'words.generated.och'} @:{'words.generated.extra'} @:{'words.generated.artiklar'} @:{'words.generated.som'} @:{'words.generated.senaste'} @:{'words.generated.tvatten'}",
|
||||
"manual_entry": "Manuell @:{'words.generated.registrering'}",
|
||||
"never_washed": "Aldrig tvättad",
|
||||
"new_order": "@.capitalize:{'words.generated.ny'} @:{'words.generated.order'}",
|
||||
"new_sale": "@.capitalize:{'words.generated.nytt'} salg",
|
||||
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.ydelse'}, @:{'terms.glossary.tilvalg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.sidste'} @:{'terms.glossary.vask'}",
|
||||
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
|
||||
"never_washed": "Aldrig vasket",
|
||||
"new_order": "@.capitalize:{'terms.glossary.ny'} order",
|
||||
"new_sale": "@.capitalize:{'terms.glossary.nyt'} @:{'terms.glossary.salg'}",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bookinger'} @:{'terms.glossary.fundet'}",
|
||||
|
||||
@@ -53,30 +53,6 @@
|
||||
"menu": {
|
||||
"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": {
|
||||
"single_subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.detaljer'} @:{'terms.glossary.om'} transaktionen",
|
||||
"subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"user_menu": {
|
||||
"my_bookings": "@:{'terms.glossary.mine'} @:{'terms.glossary.bookinger'}",
|
||||
"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_washes": "@:{'terms.glossary.mine'} @:{'terms.glossary.vaske'}"
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.gleiche'} @:{'terms.glossary.leistung'}, @:{'terms.glossary.zusatzoptionen'} @:{'terms.glossary.und'} @:{'terms.glossary.zusatzartikel'} @:{'terms.glossary.wie'} @:{'terms.glossary.bei'} @:{'terms.glossary.der'} @:{'terms.glossary.letzten'} @:{'terms.glossary.wasche'}",
|
||||
"manual_entry": "@.capitalize:{'terms.glossary.manuell'} registrering",
|
||||
"never_washed": "Noch nie gewaschen",
|
||||
"new_order": "@.capitalize:{'terms.glossary.neuer'} @:{'terms.glossary.auftrag'}",
|
||||
"new_sale": "Nytt salg",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.keine'} @:{'terms.glossary.buchungen'} @:{'terms.glossary.gefunden'}",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.service'}, @:{'terms.glossary.add'}-@:{'terms.glossary.ons'}, @:{'terms.glossary.and'} @:{'terms.glossary.extra'} @:{'terms.glossary.items'} @:{'terms.glossary.as'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.previous'} @:{'terms.glossary.wash'}",
|
||||
"manual_entry": "@:{'terms.glossary.manual'} @:{'terms.glossary.registration'}",
|
||||
"never_washed": "Never washed",
|
||||
"new_order": "@.capitalize:{'terms.glossary.new'} @:{'terms.glossary.order'}",
|
||||
"new_sale": "@.capitalize:{'terms.glossary.new'} sale",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.orders'} @:{'terms.glossary.found'}",
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
"last_wash_matches_current_selection": "@:{'phrases.compat.pos.last_wash_matches_current_selection'}",
|
||||
"license_plate": "@:{'phrases.compat.tables.common.registration_number'}",
|
||||
"manual_entry": "@:{'phrases.compat.pos.manual_entry'}",
|
||||
"last_wash": "@:{'phrases.compat.global.last_wash'}",
|
||||
"never_washed": "@:{'phrases.compat.pos.never_washed'}",
|
||||
"new_order": "@:{'phrases.compat.pos.new_order'}",
|
||||
"new_sale": "@:{'phrases.compat.pos.new_sale'}",
|
||||
"no_bookings": "@:{'phrases.compat.pos.no_bookings'}",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.tjeneste'}, @:{'terms.glossary.tillegg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.forrige'} @:{'terms.glossary.vask'}",
|
||||
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
|
||||
"never_washed": "Aldri vasket",
|
||||
"new_order": "@.capitalize:{'terms.glossary.bestillingen'}",
|
||||
"new_sale": "@.capitalize:{'terms.glossary.nytt'} @:{'terms.glossary.salg'}",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samma'} @:{'terms.glossary.tjanst'}, @:{'terms.glossary.tillval'} @:{'terms.glossary.och'} @:{'terms.glossary.extra'} @:{'terms.glossary.artiklar'} @:{'terms.glossary.som'} @:{'terms.glossary.senaste'} @:{'terms.glossary.tvatten'}",
|
||||
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
|
||||
"never_washed": "Aldrig tvättad",
|
||||
"new_order": "@.capitalize:{'terms.glossary.ny'} @:{'terms.glossary.order'}",
|
||||
"new_sale": "@.capitalize:{'terms.glossary.nytt'} salg",
|
||||
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
|
||||
|
||||
@@ -146,7 +146,6 @@ const DepartmentTimeBookingsTypes = lazyView('@/views/dashboards/departmentDashb
|
||||
const DepartmentModulesSetup = lazyView('@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue');
|
||||
const Vehicle = lazyView('@/views/dashboards/superUserDashboard/vehicle/Vehicle.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 ConfigurationEntra = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue');
|
||||
const DepartmentTimeBookingsNew = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue');
|
||||
@@ -370,12 +369,6 @@ export const router = createRouter({
|
||||
component: MyInvoices,
|
||||
meta: { middleware: authMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'mypermissions',
|
||||
path: '/user/permissions',
|
||||
component: MyPermissions,
|
||||
meta: { middleware: authMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'myorder',
|
||||
path: '/user/orders/:orderId',
|
||||
|
||||
+12
-57
@@ -1264,19 +1264,14 @@ const createPathEditorQuestion = async () => {
|
||||
await loadPathOutcomes({ force: true });
|
||||
};
|
||||
|
||||
const pathEditorProgramButtonOptions = computed(() => {
|
||||
if (!pathEditorMachineIsAvailable.value) {
|
||||
return [{ id: 0, value: "0", label: "0 = OFF" }];
|
||||
}
|
||||
return [
|
||||
{ id: 0, value: "0", label: "0 = OFF" },
|
||||
...Array.from({ length: 12 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
value: String(index + 1),
|
||||
label: `Wheel position #${index + 1}`,
|
||||
})),
|
||||
];
|
||||
});
|
||||
const pathEditorProgramButtonOptions = computed(() => [
|
||||
{ id: 0, value: "0", label: "0 = OFF" },
|
||||
...Array.from({ length: 12 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
value: String(index + 1),
|
||||
label: `Wheel position #${index + 1}`,
|
||||
})),
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -1397,56 +1392,16 @@ const pathVerificationSelectedCaseHasRun = computed(() =>
|
||||
)
|
||||
);
|
||||
|
||||
// TRU-91: Filter path editor machine options to only show enabled/available
|
||||
// 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 pathEditorMachineButtonOptions = computed(() =>
|
||||
Array.from({ length: 12 }, (_, index) => {
|
||||
const option = taskButtonOptions.find((entry) => entry.id === index);
|
||||
return {
|
||||
id: index,
|
||||
label: option?.description || `Machine button ${index}`,
|
||||
description: option?.name || `Program ${index + 1}`,
|
||||
};
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const pathEditorTaskTitleKeySlug = (key) => String(key || "").replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
|
||||
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
type ProductAggregate = {
|
||||
product_id?: number | string;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
total_amount?: number | string | null;
|
||||
};
|
||||
|
||||
type AggregatesPayload = {
|
||||
customer_number?: number | string | null;
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
primary_products: ProductAggregate[];
|
||||
addons: ProductAggregate[];
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
customer: any;
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
useMockData?: boolean;
|
||||
}>(),
|
||||
{
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
useMockData: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const AGGREGATES_ENDPOINT = "/superuser/invoicing/period/customer-aggregates";
|
||||
|
||||
const translate = (key: string, fallback: string, params: Record<string, any> = {}) => {
|
||||
const translated = t(key, params);
|
||||
return translated === key ? fallback : translated;
|
||||
};
|
||||
|
||||
const localeValue = () => (typeof locale === "string" ? locale : locale.value);
|
||||
|
||||
const aggregates = ref<AggregatesPayload | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
let pendingRequest = 0;
|
||||
|
||||
const buildMockAggregates = (): AggregatesPayload => {
|
||||
const primaryProducts: ProductAggregate[] = [
|
||||
{ product_id: "bilvask", product_name: "Bilvask", quantity: 5, total_amount: 750 },
|
||||
{ product_id: "storvask", product_name: "Storvask", quantity: 2, total_amount: 480 },
|
||||
];
|
||||
const addons: ProductAggregate[] = [
|
||||
{ product_id: "traekker", product_name: "Trækker", quantity: 1, total_amount: 25 },
|
||||
{ product_id: "spot_free", product_name: "Spot Free", quantity: 2, total_amount: 60 },
|
||||
];
|
||||
return {
|
||||
customer_number: props.customer?.customer_number ?? null,
|
||||
date_from: props.dateFrom ?? null,
|
||||
date_to: props.dateTo ?? null,
|
||||
primary_products: primaryProducts,
|
||||
addons,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchAggregates = async () => {
|
||||
const customerNumber = Number(props.customer?.customer_number || 0);
|
||||
if (customerNumber < 1) {
|
||||
aggregates.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++pendingRequest;
|
||||
isLoading.value = true;
|
||||
errorMessage.value = null;
|
||||
|
||||
try {
|
||||
if (props.useMockData) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
if (requestId !== pendingRequest) {
|
||||
return;
|
||||
}
|
||||
aggregates.value = buildMockAggregates();
|
||||
return;
|
||||
}
|
||||
|
||||
const params: Record<string, string> = { customer_number: String(customerNumber) };
|
||||
if (props.dateFrom) {
|
||||
params.date_from = props.dateFrom;
|
||||
}
|
||||
if (props.dateTo) {
|
||||
params.date_to = props.dateTo;
|
||||
}
|
||||
const response: any = await SessionUser.request(AGGREGATES_ENDPOINT, "GET", params);
|
||||
if (requestId !== pendingRequest) {
|
||||
return;
|
||||
}
|
||||
const data = response?.data?.data ?? response?.data ?? null;
|
||||
if (data && Array.isArray(data.primary_products) && Array.isArray(data.addons)) {
|
||||
aggregates.value = data as AggregatesPayload;
|
||||
} else {
|
||||
aggregates.value = { primary_products: [], addons: [] };
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (requestId !== pendingRequest) {
|
||||
return;
|
||||
}
|
||||
const status = Number(error?.response?.status || 0);
|
||||
if (status === 404 || status === 501) {
|
||||
aggregates.value = buildMockAggregates();
|
||||
errorMessage.value = translate(
|
||||
"invoicing_period.aggregates.using_mock_notice",
|
||||
"Backend endpoint not available — showing sample aggregates."
|
||||
);
|
||||
} else {
|
||||
errorMessage.value =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
translate("invoicing_period.aggregates.error", "Kunne ikke hente produkt- og tilvalgsoversigt.");
|
||||
aggregates.value = null;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === pendingRequest) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
fetchAggregates();
|
||||
};
|
||||
|
||||
defineExpose({ refresh });
|
||||
|
||||
watch(
|
||||
() => [
|
||||
Number(props.customer?.customer_number || 0),
|
||||
String(props.dateFrom || ""),
|
||||
String(props.dateTo || ""),
|
||||
Boolean(props.useMockData),
|
||||
],
|
||||
() => {
|
||||
fetchAggregates();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pendingRequest += 1;
|
||||
});
|
||||
|
||||
const hasPrimaryProducts = computed(
|
||||
() => Array.isArray(aggregates.value?.primary_products) && (aggregates.value?.primary_products?.length ?? 0) > 0
|
||||
);
|
||||
const hasAddons = computed(
|
||||
() => Array.isArray(aggregates.value?.addons) && (aggregates.value?.addons?.length ?? 0) > 0
|
||||
);
|
||||
const hasAnyAggregates = computed(() => hasPrimaryProducts.value || hasAddons.value);
|
||||
|
||||
const sortedPrimaryProducts = computed(() => {
|
||||
const list = Array.isArray(aggregates.value?.primary_products) ? [...aggregates.value!.primary_products] : [];
|
||||
return list.sort((a, b) => Number(b.quantity || 0) - Number(a.quantity || 0));
|
||||
});
|
||||
|
||||
const sortedAddons = computed(() => {
|
||||
const list = Array.isArray(aggregates.value?.addons) ? [...aggregates.value!.addons] : [];
|
||||
return list.sort((a, b) => Number(b.quantity || 0) - Number(a.quantity || 0));
|
||||
});
|
||||
|
||||
const formatQuantity = (value: any) => {
|
||||
const parsed = Number.parseInt(String(value ?? "0"), 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
||||
};
|
||||
|
||||
const formatCurrency = (value: any) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return "";
|
||||
}
|
||||
return SessionUser.functions.currency.toLocal(numeric);
|
||||
};
|
||||
|
||||
const heading = translate("invoicing_period.aggregates.heading", "Produktoversigt");
|
||||
const primaryHeading = translate("invoicing_period.aggregates.primary_products", "Primære produkter");
|
||||
const addonsHeading = translate("invoicing_period.aggregates.addons", "Tilvalg");
|
||||
const emptyLabel = translate("invoicing_period.aggregates.empty", "Ingen produkter");
|
||||
const loadingLabel = translate("invoicing_period.aggregates.loading", "Indlæser produktoversigt…");
|
||||
const totalLabel = translate("invoicing_period.aggregates.total", "i alt");
|
||||
const customerLabel = translate("invoicing_period.aggregates.for_customer", "for {name}", {
|
||||
name: String(props.customer?.customer_name || "").trim() || `#${props.customer?.customer_number ?? ""}`,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="product-aggregates-card"
|
||||
data-testid="invoicing-period-product-aggregates"
|
||||
:aria-label="heading"
|
||||
>
|
||||
<header class="product-aggregates-card__header">
|
||||
<div>
|
||||
<h3 class="product-aggregates-card__title">{{ heading }}</h3>
|
||||
<p class="product-aggregates-card__subtitle">{{ customerLabel }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-light product-aggregates-card__refresh"
|
||||
:aria-label="translate('invoicing_period.aggregates.refresh', 'Opdater produktoversigt')"
|
||||
:disabled="isLoading"
|
||||
data-testid="invoicing-period-product-aggregates-refresh"
|
||||
@click="refresh"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': isLoading }"></i>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
class="product-aggregates-card__notice"
|
||||
data-testid="invoicing-period-product-aggregates-notice"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div v-if="isLoading && !hasAnyAggregates" class="product-aggregates-card__loading" role="status" data-testid="invoicing-period-product-aggregates-loading">
|
||||
<span class="icon is-small"><i class="fas fa-circle-notch fa-spin"></i></span>
|
||||
<span>{{ loadingLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasAnyAggregates" class="product-aggregates-card__empty" data-testid="invoicing-period-product-aggregates-empty">
|
||||
<span class="icon is-small has-text-grey"><i class="fas fa-box-open"></i></span>
|
||||
<span>{{ emptyLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="product-aggregates-card__sections">
|
||||
<div class="product-aggregates-card__section" data-testid="invoicing-period-product-aggregates-primary">
|
||||
<h4 class="product-aggregates-card__section-title">
|
||||
<span class="icon is-small"><i class="fas fa-soap"></i></span>
|
||||
<span>{{ primaryHeading }}</span>
|
||||
<span class="tag is-light is-rounded">{{ sortedPrimaryProducts.length }}</span>
|
||||
</h4>
|
||||
<ul v-if="hasPrimaryProducts" class="product-aggregates-card__list">
|
||||
<li
|
||||
v-for="item in sortedPrimaryProducts"
|
||||
:key="`primary-${item.product_id ?? item.product_name}`"
|
||||
class="product-aggregates-card__item"
|
||||
:data-testid="`invoicing-period-product-aggregates-primary-item-${item.product_id ?? item.product_name}`"
|
||||
>
|
||||
<span class="product-aggregates-card__item-name">{{ item.product_name }}</span>
|
||||
<span class="tag is-info is-light product-aggregates-card__qty">{{ formatQuantity(item.quantity) }}</span>
|
||||
<span
|
||||
v-if="formatCurrency(item.total_amount) !== ''"
|
||||
class="product-aggregates-card__amount has-text-grey"
|
||||
>
|
||||
{{ formatCurrency(item.total_amount) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="product-aggregates-card__section-empty">{{ emptyLabel }}</p>
|
||||
</div>
|
||||
|
||||
<div class="product-aggregates-card__section" data-testid="invoicing-period-product-aggregates-addons">
|
||||
<h4 class="product-aggregates-card__section-title">
|
||||
<span class="icon is-small"><i class="fas fa-puzzle-piece"></i></span>
|
||||
<span>{{ addonsHeading }}</span>
|
||||
<span class="tag is-light is-rounded">{{ sortedAddons.length }}</span>
|
||||
</h4>
|
||||
<ul v-if="hasAddons" class="product-aggregates-card__list">
|
||||
<li
|
||||
v-for="item in sortedAddons"
|
||||
:key="`addon-${item.product_id ?? item.product_name}`"
|
||||
class="product-aggregates-card__item"
|
||||
:data-testid="`invoicing-period-product-aggregates-addon-item-${item.product_id ?? item.product_name}`"
|
||||
>
|
||||
<span class="product-aggregates-card__item-name">{{ item.product_name }}</span>
|
||||
<span class="tag is-warning is-light product-aggregates-card__qty">{{ formatQuantity(item.quantity) }}</span>
|
||||
<span
|
||||
v-if="formatCurrency(item.total_amount) !== ''"
|
||||
class="product-aggregates-card__amount has-text-grey"
|
||||
>
|
||||
{{ formatCurrency(item.total_amount) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="product-aggregates-card__section-empty">{{ emptyLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="hasAnyAggregates" class="product-aggregates-card__hint has-text-grey">
|
||||
<span class="icon is-small"><i class="fas fa-info-circle"></i></span>
|
||||
<span>{{ totalLabel }}: {{ sortedPrimaryProducts.length + sortedAddons.length }}</span>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-aggregates-card {
|
||||
background: #f7f9fb;
|
||||
border: 1px solid #dfe3e8;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.product-aggregates-card__header {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.product-aggregates-card__title {
|
||||
color: #263142;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-aggregates-card__subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
|
||||
.product-aggregates-card__refresh {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.product-aggregates-card__notice {
|
||||
background: #fff7e0;
|
||||
border: 1px solid #f1d27b;
|
||||
border-radius: 6px;
|
||||
color: #7a5b00;
|
||||
font-size: 0.78rem;
|
||||
margin: 0;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.product-aggregates-card__loading,
|
||||
.product-aggregates-card__empty {
|
||||
align-items: center;
|
||||
color: #536174;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.product-aggregates-card__sections {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.product-aggregates-card__section {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.product-aggregates-card__section-title {
|
||||
align-items: center;
|
||||
color: #263142;
|
||||
display: flex;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.product-aggregates-card__section-title .tag {
|
||||
font-size: 0.7rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.product-aggregates-card__list {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.product-aggregates-card__item {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
font-size: 0.82rem;
|
||||
gap: 0.4rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.product-aggregates-card__item-name {
|
||||
color: #1f2937;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-aggregates-card__qty {
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
min-width: 1.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-aggregates-card__amount {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.product-aggregates-card__section-empty {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-aggregates-card__hint {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: 0.72rem;
|
||||
gap: 0.3rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.product-aggregates-card__sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+20
@@ -109,6 +109,26 @@ const REVIEW_WORKSPACE_TRANSLATORS = {
|
||||
composer.t("invoicing_period.review_workspace.states.refreshing", params),
|
||||
"states.retained_error": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.states.retained_error", params),
|
||||
"aggregates.heading": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.heading", params),
|
||||
"aggregates.primary_products": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.primary_products", params),
|
||||
"aggregates.addons": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.addons", params),
|
||||
"aggregates.empty": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.empty", params),
|
||||
"aggregates.loading": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.loading", params),
|
||||
"aggregates.error": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.error", params),
|
||||
"aggregates.refresh": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.refresh", params),
|
||||
"aggregates.total": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.total", params),
|
||||
"aggregates.for_customer": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.for_customer", params),
|
||||
"aggregates.using_mock_notice": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.aggregates.using_mock_notice", params),
|
||||
"toolbar.all_departments": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.toolbar.all_departments", params),
|
||||
"toolbar.all_invoice_states": (composer, params) =>
|
||||
|
||||
+11
@@ -23,6 +23,7 @@ import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard
|
||||
import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
|
||||
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
|
||||
import InvoicingPeriodObjectTree from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue";
|
||||
import ProductAggregatesCard from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/ProductAggregatesCard.vue";
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
import {
|
||||
buildMultiMonthInvoiceContext,
|
||||
@@ -977,6 +978,10 @@ const getTransactionQueryParameters = () => {
|
||||
show_fixed_pricing: true,
|
||||
};
|
||||
};
|
||||
|
||||
// TRU-185: Backend product aggregate endpoint (TRU-182) is not yet merged.
|
||||
// Set to true once `/superuser/invoicing/period/customer-aggregates` is live.
|
||||
const isProductAggregatesEndpointAvailable = false;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1608,6 +1613,12 @@ const getTransactionQueryParameters = () => {
|
||||
<span v-if="Number(reason.count || 0) > 1" class="tag is-light is-rounded">{{ reason.count }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ProductAggregatesCard
|
||||
:customer="selectedCustomer"
|
||||
:date-from="dates.computed.formattedStartDate.value"
|
||||
:date-to="dates.computed.formattedEndDate.value"
|
||||
:use-mock-data="!isProductAggregatesEndpointAvailable"
|
||||
/>
|
||||
<div
|
||||
v-if="selectedCustomer.expanded"
|
||||
class="period-review-detail__tree"
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
<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>
|
||||
@@ -161,10 +161,14 @@ import {
|
||||
periodPaging,
|
||||
resetPeriodPagingState,
|
||||
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
|
||||
import { createTestI18n } from "./helpers/mountWithApp.js";
|
||||
|
||||
const viewI18n = createTestI18n();
|
||||
|
||||
const mountView = () =>
|
||||
mount(InvoicingBillingPeriodViewAll, {
|
||||
global: {
|
||||
plugins: [viewI18n],
|
||||
stubs: {
|
||||
"b-tabs": {
|
||||
template: "<div data-testid='b-tabs-stub'><slot /></div>",
|
||||
|
||||
@@ -83,6 +83,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
},
|
||||
global: {
|
||||
language: {
|
||||
last_wash: "Last wash",
|
||||
nothing_left_to_show: "Nothing left to show",
|
||||
},
|
||||
},
|
||||
@@ -133,6 +134,7 @@ const messages = {
|
||||
make: "Make",
|
||||
model: "Model",
|
||||
no_plates_found: "No license plates found",
|
||||
never_washed: "Never washed",
|
||||
recent_scans: "Recent scans",
|
||||
recent_scans_helper: "Select a license plate to view vehicle details.",
|
||||
recent_scan_details_empty: "No vehicle details are available for this scan.",
|
||||
@@ -164,6 +166,9 @@ const scans = [
|
||||
customer_name: "Pleno Logistics",
|
||||
seen_before: true,
|
||||
barred: false,
|
||||
// TRU-78: enriched by the API so the landing page can show when
|
||||
// the plate was last washed (DHL pick-up use case).
|
||||
last_wash: "2026-04-07 17:12:33",
|
||||
},
|
||||
{
|
||||
id: 802,
|
||||
@@ -174,6 +179,22 @@ const scans = [
|
||||
customer_name: "",
|
||||
seen_before: false,
|
||||
barred: false,
|
||||
// TRU-78: explicit null means the API confirmed the plate has
|
||||
// never been washed.
|
||||
last_wash: null,
|
||||
},
|
||||
{
|
||||
id: 803,
|
||||
plate: "EF11111",
|
||||
plate_scanner_id: 3,
|
||||
created_at: "2026-04-08 08:30:00",
|
||||
customer_number: null,
|
||||
customer_name: "",
|
||||
seen_before: false,
|
||||
barred: false,
|
||||
// TRU-78: missing field should be treated as "never washed" too,
|
||||
// so older backends don't break the new UI.
|
||||
// (no `last_wash` key on purpose)
|
||||
},
|
||||
];
|
||||
|
||||
@@ -341,4 +362,59 @@ describe("PosLastScannedLicensePlatesV2", () => {
|
||||
"Vehicle details could not be loaded"
|
||||
);
|
||||
});
|
||||
|
||||
// TRU-78 / DRIFT 17: license plate scan should show "last washed" on the
|
||||
// landing page so the operator can see at a glance when a trailer was
|
||||
// last washed (DHL pick-up use case).
|
||||
it("renders the last washed timestamp when the scan has a last_wash value", async () => {
|
||||
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
paginationState.list.value = scans;
|
||||
await flushUi();
|
||||
|
||||
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
|
||||
await flushUi();
|
||||
|
||||
const lastWashField = wrapper.get('[data-testid="pos-recent-scan-detail-801-last-wash"]');
|
||||
expect(lastWashField.exists()).toBe(true);
|
||||
expect(lastWashField.text()).toContain("Last wash");
|
||||
// The MySQL DATETIME "2026-04-07 17:12:33" is rendered in the user's
|
||||
// locale. We accept both the raw DATETIME string (jsdom without full
|
||||
// Intl support) and the locale-formatted output (e.g. 04/07/2026 or
|
||||
// 2026-04-07 in browsers with Intl.DateTimeFormat).
|
||||
expect(lastWashField.text()).toMatch(/2026/);
|
||||
expect(lastWashField.text()).toMatch(/04[\/.\- ]07|07[\/.\- ]04|7[\/.\- ]4|2026-04-07/);
|
||||
expect(lastWashField.text()).toMatch(/17:12/);
|
||||
});
|
||||
|
||||
it("shows the 'never washed' message when the API confirms the plate has no last_wash", async () => {
|
||||
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
paginationState.list.value = scans;
|
||||
await flushUi();
|
||||
|
||||
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
|
||||
await flushUi();
|
||||
|
||||
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-802"]');
|
||||
expect(neverWashed.exists()).toBe(true);
|
||||
expect(neverWashed.text()).toContain("Never washed");
|
||||
});
|
||||
|
||||
it("falls back to 'never washed' when the scan has no last_wash key at all", async () => {
|
||||
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
paginationState.list.value = scans;
|
||||
await flushUi();
|
||||
|
||||
await wrapper.get('[data-testid="pos-recent-scan-row-803"]').trigger("click");
|
||||
await flushUi();
|
||||
|
||||
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-803"]');
|
||||
expect(neverWashed.exists()).toBe(true);
|
||||
expect(neverWashed.text()).toContain("Never washed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user