Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b58c1eb9ec |
@@ -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,
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
+11
@@ -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),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
+239
-4
@@ -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>
|
||||
|
||||
+66
-1
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user