Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard b58c1eb9ec Add daily report product target controls 2026-07-06 19:10:50 +02:00
21 changed files with 694 additions and 9 deletions
+9
View File
@@ -126,6 +126,15 @@ export const sourceMappings = [
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
projects: chromiumProjects,
},
{
name: "admin-daily-report",
patterns: [
/^src\/views\/dashboards\/departmentDashboard\/modules\/daily-report\//u,
/^src\/components\/session\/token\/SessionUser\/Objects\/DepartmentDailyReports\.vue$/u,
],
specs: ["tests/e2e/admin-daily-report.spec.ts"],
projects: chromiumProjects,
},
{
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u],
@@ -164,6 +164,21 @@ const t = (key) => i18n.global.t(key);
}
);
},
setProductTarget: async ({ department_id, product_id, target_percentage }) => {
const normalizedTarget = target_percentage === null || target_percentage === undefined || target_percentage === ""
? null
: Number(target_percentage);
return authenticatedRequest(
"/departments/daily-reports/product-targets",
"PUT",
{
department_id: parseInt(department_id, 10),
product_id: parseInt(product_id, 10),
target_percentage: normalizedTarget,
}
);
},
createComplaint: async ({
department_id,
customer_number = null,
+8
View File
@@ -1517,6 +1517,14 @@
"subtitle_suffix": "@:{'words.generated.salg'} @:{'words.generated.af'} produktet",
"title": "Produktsalg"
},
"product_targets": {
"cancel_aria": "Annuller mål",
"edit_aria": "Rediger målprocent",
"input_label": "Målprocent",
"save_aria": "Gem mål",
"save_error": "Målet kunne ikke gemmes.",
"validation_range": "Målprocenten skal være mellem 0 og 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'words.generated.antal'} @:{'words.generated.fuldførte'}",
+8
View File
@@ -1628,6 +1628,14 @@
"subtitle_suffix": "Produktverk?@:{'words.generated.ufe'}",
"title": "@:{'templates.generated.compat.admin.daily_report.product_sales.subtitle_suffix'}"
},
"product_targets": {
"cancel_aria": "Ziel abbrechen",
"edit_aria": "Zielprozentsatz bearbeiten",
"input_label": "Zielprozentsatz",
"save_aria": "Ziel speichern",
"save_error": "Das Ziel konnte nicht gespeichert werden.",
"validation_range": "Der Zielprozentsatz muss zwischen 0 und 100 liegen."
},
"reports": {
"bookings": {
"completed_count": "@:{'words.generated.abgeschlossene'} @:{'words.generated.anzahl'}",
+8
View File
@@ -1349,6 +1349,14 @@
"subtitle_suffix": "@:{'words.generated.product'} @:{'words.generated.sales'}",
"title": "@.capitalize:{'words.generated.product'} @.capitalize:{'words.generated.sales'}"
},
"product_targets": {
"cancel_aria": "Cancel target",
"edit_aria": "Edit target percentage",
"input_label": "Target percentage",
"save_aria": "Save target",
"save_error": "The target could not be saved.",
"validation_range": "The target percentage must be between 0 and 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'words.generated.completed'} @:{'words.generated.count'}",
+8
View File
@@ -126,6 +126,14 @@
"subtitle_suffix": "@:{'templates.generated.compat.admin.daily_report.product_sales.subtitle_suffix'}",
"title": "@:{'templates.generated.compat.admin.daily_report.product_sales.title'}"
},
"product_targets": {
"cancel_aria": "@:{'templates.generated.compat.admin.daily_report.product_targets.cancel_aria'}",
"edit_aria": "@:{'templates.generated.compat.admin.daily_report.product_targets.edit_aria'}",
"input_label": "@:{'templates.generated.compat.admin.daily_report.product_targets.input_label'}",
"save_aria": "@:{'templates.generated.compat.admin.daily_report.product_targets.save_aria'}",
"save_error": "@:{'templates.generated.compat.admin.daily_report.product_targets.save_error'}",
"validation_range": "@:{'templates.generated.compat.admin.daily_report.product_targets.validation_range'}"
},
"reports": {
"avg_revenue": {
"label": "@:{'templates.generated.compat.department_reports.avg_revenue_per_wash'}",
+8
View File
@@ -1631,6 +1631,14 @@
"subtitle_suffix": "@:{'words.generated.produktsalg'}",
"title": "@.capitalize:{'words.generated.produktsalg'}"
},
"product_targets": {
"cancel_aria": "Avbryt mål",
"edit_aria": "Rediger målprosent",
"input_label": "Målprosent",
"save_aria": "Lagre mål",
"save_error": "Målet kunne ikke lagres.",
"validation_range": "Målprosenten må være mellom 0 og 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'words.generated.fullført'} @:{'words.generated.telling'}",
+8
View File
@@ -1681,6 +1681,14 @@
"subtitle_suffix": "@:{'words.generated.produktforsaljning'}",
"title": "@.capitalize:{'words.generated.produktforsaljning'}"
},
"product_targets": {
"cancel_aria": "Avbryt mål",
"edit_aria": "Redigera målprocent",
"input_label": "Målprocent",
"save_aria": "Spara mål",
"save_error": "Målet kunde inte sparas.",
"validation_range": "Målprocenten måste vara mellan 0 och 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'words.generated.antal'} @:{'words.generated.slutforda'}",
@@ -27,6 +27,14 @@
"subtitle_suffix": "@:{'terms.glossary.salg'} @:{'terms.glossary.af'} produktet",
"title": "Produktsalg"
},
"product_targets": {
"cancel_aria": "Annuller mål",
"edit_aria": "Rediger målprocent",
"input_label": "Målprocent",
"save_aria": "Gem mål",
"save_error": "Målet kunne ikke gemmes.",
"validation_range": "Målprocenten skal være mellem 0 og 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'terms.glossary.antal'} @:{'terms.glossary.fuldførte'}",
@@ -27,6 +27,14 @@
"subtitle_suffix": "Produktverk?@:{'terms.glossary.ufe'}",
"title": "@:{'phrases.compat.admin.daily_report.product_sales.subtitle_suffix'}"
},
"product_targets": {
"cancel_aria": "Ziel abbrechen",
"edit_aria": "Zielprozentsatz bearbeiten",
"input_label": "Zielprozentsatz",
"save_aria": "Ziel speichern",
"save_error": "Das Ziel konnte nicht gespeichert werden.",
"validation_range": "Der Zielprozentsatz muss zwischen 0 und 100 liegen."
},
"reports": {
"bookings": {
"completed_count": "@:{'terms.glossary.abgeschlossene'} @:{'terms.glossary.anzahl'}",
@@ -27,6 +27,14 @@
"subtitle_suffix": "@:{'terms.glossary.product'} @:{'terms.glossary.sales'}",
"title": "@.capitalize:{'terms.glossary.product'} @.capitalize:{'terms.glossary.sales'}"
},
"product_targets": {
"cancel_aria": "Cancel target",
"edit_aria": "Edit target percentage",
"input_label": "Target percentage",
"save_aria": "Save target",
"save_error": "The target could not be saved.",
"validation_range": "The target percentage must be between 0 and 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'terms.glossary.completed'} @:{'terms.glossary.count'}",
@@ -34,6 +34,14 @@
"subtitle_suffix": "@:{'phrases.compat.admin.daily_report.product_sales.subtitle_suffix'}",
"title": "@:{'phrases.compat.admin.daily_report.product_sales.title'}"
},
"product_targets": {
"cancel_aria": "@:{'phrases.compat.admin.daily_report.product_targets.cancel_aria'}",
"edit_aria": "@:{'phrases.compat.admin.daily_report.product_targets.edit_aria'}",
"input_label": "@:{'phrases.compat.admin.daily_report.product_targets.input_label'}",
"save_aria": "@:{'phrases.compat.admin.daily_report.product_targets.save_aria'}",
"save_error": "@:{'phrases.compat.admin.daily_report.product_targets.save_error'}",
"validation_range": "@:{'phrases.compat.admin.daily_report.product_targets.validation_range'}"
},
"reports": {
"avg_revenue": {
"label": "@:{'phrases.compat.department_reports.avg_revenue_per_wash'}",
@@ -27,6 +27,14 @@
"subtitle_suffix": "@:{'terms.glossary.produktsalg'}",
"title": "@.capitalize:{'terms.glossary.produktsalg'}"
},
"product_targets": {
"cancel_aria": "Avbryt mål",
"edit_aria": "Rediger målprosent",
"input_label": "Målprosent",
"save_aria": "Lagre mål",
"save_error": "Målet kunne ikke lagres.",
"validation_range": "Målprosenten må være mellom 0 og 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'terms.glossary.fullført'} @:{'terms.glossary.telling'}",
@@ -27,6 +27,14 @@
"subtitle_suffix": "@:{'terms.glossary.produktforsaljning'}",
"title": "@.capitalize:{'terms.glossary.produktforsaljning'}"
},
"product_targets": {
"cancel_aria": "Avbryt mål",
"edit_aria": "Redigera målprocent",
"input_label": "Målprocent",
"save_aria": "Spara mål",
"save_error": "Målet kunde inte sparas.",
"validation_range": "Målprocenten måste vara mellan 0 och 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'terms.glossary.antal'} @:{'terms.glossary.slutforda'}",
@@ -77,6 +77,15 @@ const normalizeMetricState = (metric, fallbackState = "ready", fallbackMessage =
message: metric?.message || fallbackMessage,
});
const normalizeNullableNumber = (value) => {
if (value === null || value === undefined || value === "") {
return null;
}
const normalized = Number(value);
return Number.isFinite(normalized) ? normalized : null;
};
const resetOverviewState = () => {
count_transactions.value = 0;
count_products_sold.value = 0;
@@ -149,6 +158,8 @@ const applyOverviewResponse = (overview) => {
value: Number(product.value || 0),
out_of: Number(product.out_of || 0),
state: product.state || "ready",
target_percentage: normalizeNullableNumber(product.target_percentage),
target_department_id: normalizeNullableNumber(product.target_department_id),
},
])
);
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue';
import { ref, watch, onMounted, computed, nextTick } from 'vue';
import {BSkeleton} from "buefy";
import { useI18n } from 'vue-i18n';
@@ -76,6 +76,18 @@ const props = defineProps({
type: Function,
default: () => {}
},
canEditTarget: {
type: Boolean,
default: false,
},
targetPercentage: {
type: [Number, String],
default: null,
},
saveTargetFunction: {
type: Function,
default: null,
},
styleContent: {
type: Object,
default: () => {
@@ -84,6 +96,12 @@ const props = defineProps({
}
});
const percentageElement = ref<HTMLElement|null>(null);
const targetInputElement = ref<HTMLInputElement|null>(null);
const isTargetEditorOpen = ref(false);
const targetInputValue = ref("");
const isSavingTarget = ref(false);
const targetError = ref("");
const getPercentage = () => {
if (props.state !== "ready") {
return null;
@@ -93,6 +111,91 @@ const getPercentage = () => {
}
return null;
}
const formatPercentageValue = (value: number) => `${value.toFixed(1)}%`;
const normalizedTargetPercentage = computed(() => {
if (props.targetPercentage === null || props.targetPercentage === undefined || props.targetPercentage === "") {
return null;
}
const numericTarget = Number(props.targetPercentage);
return Number.isFinite(numericTarget) ? numericTarget : null;
});
const hasTargetPercentage = computed(() => normalizedTargetPercentage.value !== null);
const formattedTargetPercentage = computed(() => (
normalizedTargetPercentage.value === null ? "" : formatPercentageValue(normalizedTargetPercentage.value)
));
const canUseTargetEditor = computed(() => Boolean(
props.canEditTarget && props.saveTargetFunction && getPercentage() !== null && !props.isLoading
));
const percentageButtonTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-percentage-button` : null));
const targetButtonTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-button` : null));
const targetInputTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-input` : null));
const targetSaveTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-save` : null));
const targetCancelTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-cancel` : null));
const targetErrorTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-error` : null));
const openTargetEditor = async () => {
if (!canUseTargetEditor.value) {
return;
}
targetInputValue.value = normalizedTargetPercentage.value === null
? ""
: String(normalizedTargetPercentage.value);
targetError.value = "";
isTargetEditorOpen.value = true;
await nextTick();
targetInputElement.value?.focus();
autoAdjustedFontSizeTitle.value = determineTitleFontSize(props.title);
};
const closeTargetEditor = () => {
targetError.value = "";
isTargetEditorOpen.value = false;
nextTick(() => {
autoAdjustedFontSizeTitle.value = determineTitleFontSize(props.title);
});
};
const parseTargetInputValue = () => {
const rawValue = String(targetInputValue.value || "").trim().replace(",", ".");
if (rawValue === "") {
return { valid: true, value: null };
}
const numericTarget = Number(rawValue);
if (!Number.isFinite(numericTarget) || numericTarget < 0 || numericTarget > 100) {
return { valid: false, value: null };
}
return { valid: true, value: Math.round(numericTarget * 10) / 10 };
};
const saveTarget = async () => {
if (!canUseTargetEditor.value || isSavingTarget.value) {
return;
}
const parsedTarget = parseTargetInputValue();
if (!parsedTarget.valid) {
targetError.value = t("admin.daily_report.product_targets.validation_range");
return;
}
isSavingTarget.value = true;
targetError.value = "";
try {
await props.saveTargetFunction(parsedTarget.value);
closeTargetEditor();
} catch (error) {
console.error("Failed to save daily report product target", error);
targetError.value = t("admin.daily_report.product_targets.save_error");
} finally {
isSavingTarget.value = false;
}
};
const getAveragePercentage = () => {
return null; // TODO: Average percentage
}
@@ -166,6 +269,11 @@ watch(() => props.count, (newVal) => {
autoAdjustedFontSize.value = determineFontSize(newVal);
determineTitleFontSize(props.title);
}, { immediate: true });
watch(() => [props.targetPercentage, isTargetEditorOpen.value], () => {
nextTick(() => {
autoAdjustedFontSizeTitle.value = determineTitleFontSize(props.title);
});
}, { immediate: true });
onMounted(() => {
autoAdjustedFontSizeTitle.value = determineTitleFontSize(props.title);
@@ -180,12 +288,19 @@ const isUnavailable = computed(() => props.state === "unavailable");
<template>
<div class="card" @click="clickFunction" :data-testid="dataTestid || null">
<div class="card-content" :style="styleContent">
<div class="is-pulled-right has-text-right is-flex is-flex-direction-column is-4" style="gap: 4px;" v-if="getPercentage() !== null" ref="percentageElement">
<button class="button is-small is-info" :class="{
<div class="daily-report-count__percentage-stack has-text-right is-flex is-flex-direction-column is-4" v-if="getPercentage() !== null" ref="percentageElement">
<button
type="button"
class="button is-small is-info"
:class="{
'has-background-success has-text-black': getAveragePercentage() !== null && isAverage(),
'has-background-danger has-text-black': getAveragePercentage() !== null && !isAverage(),
'is-outlined': getAveragePercentage() === null
}">
}"
:aria-label="canUseTargetEditor ? t('admin.daily_report.product_targets.edit_aria') : null"
:data-testid="percentageButtonTestid"
@click.stop="openTargetEditor"
>
<!--<span class="icon is-small">
<i class="fas fa-sync-alt"></i>
</span>-->
@@ -197,6 +312,75 @@ const isUnavailable = computed(() => props.state === "unavailable");
</template>
</button>
<form
v-if="isTargetEditorOpen"
class="daily-report-count__target-editor"
@submit.prevent.stop="saveTarget"
@click.stop
>
<div class="field has-addons daily-report-count__target-editor-controls">
<div class="control">
<input
ref="targetInputElement"
v-model="targetInputValue"
class="input is-small daily-report-count__target-input"
type="number"
min="0"
max="100"
step="0.1"
inputmode="decimal"
:aria-label="t('admin.daily_report.product_targets.input_label')"
:data-testid="targetInputTestid"
>
</div>
<div class="control">
<button
class="button is-small is-success"
type="submit"
:class="{ 'is-loading': isSavingTarget }"
:aria-label="t('admin.daily_report.product_targets.save_aria')"
:data-testid="targetSaveTestid"
>
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
</button>
</div>
<div class="control">
<button
class="button is-small"
type="button"
:aria-label="t('admin.daily_report.product_targets.cancel_aria')"
:data-testid="targetCancelTestid"
@click.stop="closeTargetEditor"
>
<span class="icon is-small">
<i class="fas fa-times"></i>
</span>
</button>
</div>
</div>
<p
v-if="targetError"
class="help is-danger daily-report-count__target-error"
:data-testid="targetErrorTestid"
>
{{ targetError }}
</p>
</form>
<button
v-else-if="hasTargetPercentage"
type="button"
class="button is-small is-info is-outlined daily-report-count__target-button"
:aria-label="canUseTargetEditor ? t('admin.daily_report.product_targets.edit_aria') : null"
:data-testid="targetButtonTestid"
@click.stop="openTargetEditor"
>
<i class="fas fa-bullseye daily-report-count__target-background-icon" aria-hidden="true"></i>
<span class="daily-report-count__target-value">{{ formattedTargetPercentage }}</span>
</button>
<button v-if="getAveragePercentage()" class="button is-small is-info is-outlined">
<!--<span class="icon is-small">
<i class="fas fa-sync-alt"></i>
@@ -282,5 +466,56 @@ const isUnavailable = computed(() => props.state === "unavailable");
</template>
<style scoped>
.card-content {
position: relative;
}
.daily-report-count__percentage-stack {
align-items: flex-end;
gap: 4px;
position: absolute;
right: 1.25rem;
top: 1.25rem;
z-index: 2;
}
.daily-report-count__target-button {
min-width: 64px;
overflow: hidden;
position: relative;
}
.daily-report-count__target-background-icon {
font-size: 1.35rem;
left: 50%;
opacity: 0.16;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
}
.daily-report-count__target-value {
position: relative;
z-index: 1;
}
.daily-report-count__target-editor {
width: 154px;
}
.daily-report-count__target-editor-controls {
justify-content: flex-end;
margin-bottom: 0;
}
.daily-report-count__target-input {
max-width: 68px;
text-align: right;
}
.daily-report-count__target-error {
max-width: 154px;
white-space: normal;
}
</style>
@@ -1,9 +1,16 @@
<script setup>
import { computed } from "vue";
import { Colors } from "@/ThemeConfig.vue";
import { daily_report_products } from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
daily_report_products,
refreshOverview,
selected_department_ids,
} from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
import DepartmentDashboardDailyReportCount from "@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportCount.vue";
const SET_PRODUCT_TARGET_PERMISSION = "set_department_daily_report_product_targets";
const FALLBACK_PRODUCT_TITLES = {
24: "Spot Free (Lastbil)",
25: "Fælg flex pr. enhed",
@@ -51,6 +58,8 @@ const productTile = computed(() => {
out_of: 0,
state: "ready",
message: null,
target_percentage: null,
target_department_id: null,
};
});
@@ -70,6 +79,59 @@ const resolvedTitle = computed(() => {
return FALLBACK_PRODUCT_TITLES[props.product_id] || null;
});
const selectedDepartmentId = computed(() => {
const selectedDepartmentIds = Array.isArray(selected_department_ids.value)
? selected_department_ids.value.map((departmentId) => Number(departmentId)).filter((departmentId) => departmentId > 0)
: [];
return selectedDepartmentIds.length === 1 ? selectedDepartmentIds[0] : null;
});
const targetPercentage = computed(() => {
const target = productTile.value?.target_percentage;
if (target === null || target === undefined || target === "") {
return null;
}
const numericTarget = Number(target);
return Number.isFinite(numericTarget) ? numericTarget : null;
});
const canEditProductTarget = computed(() => Boolean(
selectedDepartmentId.value !== null && SessionUser.hasPermission(SET_PRODUCT_TARGET_PERMISSION)
));
const saveProductTarget = async (nextTargetPercentage) => {
const departmentId = selectedDepartmentId.value;
if (departmentId === null) {
return null;
}
const response = await SessionUser.objects.department_daily_reports.functions.setProductTarget({
department_id: departmentId,
product_id: props.product_id,
target_percentage: nextTargetPercentage,
});
const savedTarget = response?.data?.data?.target_percentage ?? nextTargetPercentage;
const normalizedTarget = savedTarget === null || savedTarget === undefined || savedTarget === ""
? null
: Number(savedTarget);
daily_report_products.value = {
...daily_report_products.value,
[props.product_id]: {
...productTile.value,
product_id: props.product_id,
target_percentage: Number.isFinite(normalizedTarget) ? normalizedTarget : null,
target_department_id: Number.isFinite(normalizedTarget) ? departmentId : null,
},
};
await refreshOverview();
return response;
};
</script>
<template>
@@ -83,6 +145,9 @@ const resolvedTitle = computed(() => {
:isLoading="is_loading"
:state="productTile.state || 'ready'"
:unavailable-message="productTile.message || 'Ikke tilgængelig'"
:can-edit-target="canEditProductTarget"
:target-percentage="targetPercentage"
:save-target-function="saveProductTarget"
:style-content="styleContent"
:data-testid="dataTestid"
/>
+133 -4
View File
@@ -4,6 +4,8 @@ import { isCompactProject } from "./support/projects";
const adminPermissions = [
"admin",
"list_department_daily_reports",
"list_bookings",
"create_department_daily_report_complaints",
"department_access_1",
"department_access_2",
@@ -26,8 +28,26 @@ const buildOverviewPayload = ({
complaintsMessage = null,
metrics = {},
products = [
{ product_id: 24, slug: "spot-free-lastbil", title: "Spot Free (Lastbil)", state: "ready", value: 3, out_of: 8 },
{ product_id: 25, slug: "faelg-flex", title: "Fælg flex pr. enhed", state: "ready", value: 2, out_of: 8 },
{
product_id: 24,
slug: "spot-free-lastbil",
title: "Spot Free (Lastbil)",
state: "ready",
value: 3,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{
product_id: 25,
slug: "faelg-flex",
title: "Fælg flex pr. enhed",
state: "ready",
value: 2,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{
product_id: 27,
slug: "extraordinary-10-min",
@@ -35,6 +55,8 @@ const buildOverviewPayload = ({
state: "ready",
value: 1,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{
product_id: 26,
@@ -43,8 +65,19 @@ const buildOverviewPayload = ({
state: "ready",
value: 4,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{
product_id: 21,
slug: "undervognsskyl",
title: "Undervognsskyl pr. enhed",
state: "ready",
value: 2,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{ product_id: 21, slug: "undervognsskyl", title: "Undervognsskyl pr. enhed", state: "ready", value: 2, out_of: 8 },
{
product_id: 22,
slug: "double-duty-kemi",
@@ -52,6 +85,8 @@ const buildOverviewPayload = ({
state: "ready",
value: 5,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
],
...overrides
@@ -92,6 +127,9 @@ async function mockDailyReportDependencies(
body: Record<string, unknown>
) => Promise<ReturnType<typeof json>> | ReturnType<typeof json>;
complaintCustomerLookupHandler?: (url: URL) => Promise<ReturnType<typeof json>> | ReturnType<typeof json>;
productTargetHandler?: (
body: Record<string, unknown>
) => Promise<ReturnType<typeof json>> | ReturnType<typeof json>;
permissions?: string[];
}
| ((url: URL) => Promise<ReturnType<typeof json>> | ReturnType<typeof json>)
@@ -114,6 +152,16 @@ async function mockDailyReportDependencies(
},
}));
const complaintCustomerLookupHandler = options.complaintCustomerLookupHandler || (async () => json({ data: [] }));
const productTargetHandler =
options.productTargetHandler ||
(async (body) =>
json({
data: {
department_id: body.department_id,
product_id: body.product_id,
target_percentage: body.target_percentage ?? null,
},
}));
await seedAuthenticatedState(page);
await mockApi(page, {
@@ -167,6 +215,12 @@ async function mockDailyReportDependencies(
await route.fulfill(response);
});
await page.route(/\/departments\/daily-reports\/product-targets$/i, async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>;
const response = await productTargetHandler(body);
await route.fulfill(response);
});
await page.route(/\/departments\/daily-reports(?:\/overview)?(\?.*)?$/i, async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/departments/daily-reports/overview")) {
@@ -199,7 +253,7 @@ test.describe("Admin daily report", () => {
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-23");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await expect(page.locator("[data-testid^='daily-report-tile-']")).toHaveCount(17);
await expect(page.locator(".card[data-testid^='daily-report-tile-']")).toHaveCount(17);
await expect(page.getByTestId("daily-report-tile-bookings")).toContainText("4");
await expect(page.getByTestId("daily-report-tile-complaints")).toContainText("2");
await expect(page.getByTestId("daily-report-complaints-add-button")).toBeVisible();
@@ -216,6 +270,81 @@ test.describe("Admin daily report", () => {
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
});
test("lets permitted users set a target from an optional product percentage", async ({ page }) => {
let targetPercentage: number | null = null;
const targetRequests: Array<Record<string, unknown>> = [];
await mockDailyReportDependencies(page, {
permissions: [...adminPermissions, "set_department_daily_report_product_targets"],
overviewHandler: async () =>
json(
buildOverviewPayload({
products: [
{
product_id: 24,
slug: "spot-free-lastbil",
title: "Spot Free (Lastbil)",
state: "ready",
value: 3,
out_of: 8,
target_percentage: targetPercentage,
target_department_id: targetPercentage === null ? null : 1,
},
],
})
),
productTargetHandler: async (body) => {
targetRequests.push(body);
targetPercentage = body.target_percentage === null ? null : Number(body.target_percentage);
return json({
data: {
department_id: body.department_id,
product_id: body.product_id,
target_percentage: targetPercentage,
},
});
},
});
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-23");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
const productTile = page.getByTestId("daily-report-tile-spot-free-lastbil");
const percentageButton = page.getByTestId("daily-report-tile-spot-free-lastbil-percentage-button");
await expect(percentageButton).toBeVisible();
await expect(percentageButton).toHaveClass(/is-outlined/);
await expect(page.locator('[data-testid="daily-report-tile-spot-free-lastbil-target-button"]')).toHaveCount(0);
await percentageButton.click();
await page.getByTestId("daily-report-tile-spot-free-lastbil-target-input").fill("55.5");
await page.getByTestId("daily-report-tile-spot-free-lastbil-target-save").click();
await expect.poll(() => targetRequests.length).toBe(1);
expect(targetRequests[0]).toMatchObject({
department_id: 1,
product_id: 24,
target_percentage: 55.5,
});
await expect(page.getByTestId("daily-report-tile-spot-free-lastbil-target-button")).toContainText("55.5%");
await expect(productTile.locator(".daily-report-count__target-background-icon")).toHaveCount(1);
});
test("does not open the product target editor without permission", async ({ page }) => {
await mockDailyReportDependencies(page, {
overviewHandler: async () => json(buildOverviewPayload()),
permissions: adminPermissions,
});
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-23");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await page.getByTestId("daily-report-tile-spot-free-lastbil-percentage-button").click();
await expect(page.locator('[data-testid="daily-report-tile-spot-free-lastbil-target-input"]')).toHaveCount(0);
await expect(page.locator('[data-testid="daily-report-tile-spot-free-lastbil-target-button"]')).toHaveCount(0);
});
test("renders rounded-up weather hours and keeps unknown productivity gray", async ({ page }) => {
await mockDailyReportDependencies(page, {
overviewHandler: async () => json(buildOverviewPayload()),
@@ -85,6 +85,18 @@ const makeOverview = ({
state: "ready",
value: 3,
out_of: 9,
target_percentage: "56.5",
target_department_id: "2",
},
{
product_id: 25,
slug: "faelg-flex",
title: "Fælg flex pr. enhed",
state: "ready",
value: 0,
out_of: 9,
target_percentage: 0,
target_department_id: 2,
},
],
},
@@ -215,6 +227,15 @@ describe("DepartmentDailyReportObject behavior", () => {
slug: "spot-free-lastbil",
value: 3,
out_of: 9,
target_percentage: 56.5,
target_department_id: 2,
});
expect(module.daily_report_products.value[25]).toMatchObject({
slug: "faelg-flex",
value: 0,
out_of: 9,
target_percentage: 0,
target_department_id: 2,
});
});
@@ -1,11 +1,31 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
const dailyReportProductsState = vi.hoisted(() => ({ value: {} }));
const selectedDepartmentIdsState = vi.hoisted(() => ({ value: [] }));
const refreshOverviewMock = vi.hoisted(() => vi.fn());
const hasPermissionMock = vi.hoisted(() => vi.fn());
const setProductTargetMock = vi.hoisted(() => vi.fn());
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue", () => ({
daily_report_products: dailyReportProductsState,
selected_department_ids: selectedDepartmentIdsState,
refreshOverview: refreshOverviewMock,
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
hasPermission: hasPermissionMock,
objects: {
department_daily_reports: {
functions: {
setProductTarget: setProductTargetMock,
},
},
},
},
}));
vi.mock("vue-i18n", async (importOriginal) => {
@@ -24,6 +44,12 @@ import DepartmentDashboardDailyReportProductSales from "@/views/dashboards/depar
describe("DepartmentDashboardDailyReportProductSales behavior", () => {
beforeEach(() => {
dailyReportProductsState.value = {};
selectedDepartmentIdsState.value = [];
refreshOverviewMock.mockReset();
hasPermissionMock.mockReset();
hasPermissionMock.mockReturnValue(false);
setProductTargetMock.mockReset();
setProductTargetMock.mockResolvedValue({ data: { data: { target_percentage: null } } });
});
it("renders the API title when the overview payload includes one", () => {
@@ -79,4 +105,71 @@ describe("DepartmentDashboardDailyReportProductSales behavior", () => {
expect(missingTileWrapper.text()).toContain("Tillæg for Specialsæbe - DD");
});
it("shows a saved target percentage below the product percentage", () => {
dailyReportProductsState.value = {
24: {
title: "Spot Free (Lastbil)",
value: 3,
out_of: 8,
state: "ready",
message: null,
target_percentage: 62.5,
target_department_id: 1,
},
};
const wrapper = mount(DepartmentDashboardDailyReportProductSales, {
props: {
product_id: 24,
subtitle: "Dagens salg af produktet",
dataTestid: "daily-report-tile-spot-free-lastbil",
},
});
expect(wrapper.get('[data-testid="daily-report-tile-spot-free-lastbil-target-button"]').text()).toContain("62.5%");
});
it("opens the target editor from the percentage button and saves through the daily report API", async () => {
selectedDepartmentIdsState.value = [1];
hasPermissionMock.mockImplementation((permission) => permission === "set_department_daily_report_product_targets");
setProductTargetMock.mockResolvedValue({ data: { data: { target_percentage: 70 } } });
dailyReportProductsState.value = {
24: {
title: "Spot Free (Lastbil)",
value: 3,
out_of: 8,
state: "ready",
message: null,
target_percentage: null,
target_department_id: null,
},
};
const wrapper = mount(DepartmentDashboardDailyReportProductSales, {
props: {
product_id: 24,
subtitle: "Dagens salg af produktet",
dataTestid: "daily-report-tile-spot-free-lastbil",
},
});
await wrapper.get('[data-testid="daily-report-tile-spot-free-lastbil-percentage-button"]').trigger("click");
await nextTick();
await wrapper.get('[data-testid="daily-report-tile-spot-free-lastbil-target-input"]').setValue("70");
await wrapper.find("form").trigger("submit");
await nextTick();
await Promise.resolve();
expect(setProductTargetMock).toHaveBeenCalledWith({
department_id: 1,
product_id: 24,
target_percentage: 70,
});
expect(refreshOverviewMock).toHaveBeenCalledTimes(1);
expect(dailyReportProductsState.value[24]).toMatchObject({
target_percentage: 70,
target_department_id: 1,
});
});
});
+11
View File
@@ -39,6 +39,17 @@ describe("Playwright PR mapping", () => {
).toContain("tests/e2e/admin-department-notifications.spec.ts");
});
it("maps admin daily-report changes to daily-report E2E coverage", () => {
expect(
specsFor(
"src/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportProductSales.vue"
)
).toContain("tests/e2e/admin-daily-report.spec.ts");
expect(specsFor("src/components/session/token/SessionUser/Objects/DepartmentDailyReports.vue")).toContain(
"tests/e2e/admin-daily-report.spec.ts"
);
});
it("maps limited backoffice view changes to limited backoffice E2E coverage", () => {
expect(specsFor("src/views/backoffice/LimitedBackofficePrices.vue")).toContain(
"tests/e2e/limited-backoffice.spec.ts"