Files
pleno-vue/src/components/displays/department/pricing/DepartmentCustomerPricingEditor.vue
T
Jeppe B aaecffbdfa Finish Sæby demo registration and limited backoffice (#242)
Unify truckwash.dk Kundeoprettelse and QR traffic on the shared customer page, add protected registration UX, and complete the limited-backoffice demo flow.
2026-08-02 11:50:58 +02:00

665 lines
22 KiB
Vue

<script setup>
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue";
import {
getLimitedBackofficeDepartmentCustomerPricing,
getSuperuserDepartmentCustomerPricing,
unwrapDepartmentCustomerPricingResponse,
updateLimitedBackofficeDepartmentCustomerPricing,
updateSuperuserDepartmentCustomerPricing,
} from "@/services/departmentCustomerPricing.js";
const props = defineProps({
scope: {
type: String,
required: true,
validator: (value) => ["superuser", "limited"].includes(value),
},
departmentId: {
type: [Number, String],
required: true,
},
customPricingEnabled: {
type: Boolean,
default: false,
},
canRead: {
type: Boolean,
default: false,
},
canEdit: {
type: Boolean,
default: false,
},
initialCustomerNumber: {
type: [Number, String],
default: "",
},
presentation: {
type: String,
default: "plain",
validator: (value) => ["plain", "superuser-tiles"].includes(value),
},
});
const { t, locale } = useI18n({ useScope: "global" });
const customerNumber = ref(String(props.initialCustomerNumber || ""));
const pricingData = ref(null);
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const pricingConflict = ref(false);
const departmentId = computed(() => {
const parsed = Number.parseInt(String(props.departmentId || ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const parsedCustomerNumber = computed(() => {
const parsed = Number.parseInt(String(customerNumber.value || "").trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const canLoadPricing = computed(
() =>
props.canRead && props.customPricingEnabled && departmentId.value !== null && parsedCustomerNumber.value !== null
);
const categories = computed(() => (Array.isArray(pricingData.value?.categories) ? pricingData.value.categories : []));
const hasProducts = computed(() => categories.value.some((category) => (category.products || []).length > 0));
const customer = computed(() => pricingData.value?.customer || null);
const customerDisplay = computed(() => {
if (!customer.value) {
return "";
}
return [customer.value.display_name, customer.value.customer_number ? `#${customer.value.customer_number}` : null]
.filter(Boolean)
.join(" - ");
});
const formatPrice = (price) => {
if (price === null || price === undefined || price === "") {
return "-";
}
return new Intl.NumberFormat(locale.value || undefined, {
style: "currency",
currency: "DKK",
maximumFractionDigits: 0,
}).format(Number(price || 0));
};
const overrideKey = (isCategory, objectId) => `${isCategory ? 1 : 0}:${String(objectId)}`;
const overridesByKey = computed(() => {
const map = new Map();
(pricingData.value?.overrides || []).forEach((override) => {
map.set(overrideKey(Boolean(override.is_category), override.product_or_category_id), {
is_category: Boolean(override.is_category),
product_or_category_id: override.product_or_category_id,
percentage: Number.parseInt(String(override.percentage ?? 0), 10) || 0,
fixed_price:
override.fixed_price === null || override.fixed_price === undefined
? null
: Number.parseInt(String(override.fixed_price), 10),
});
});
return map;
});
const getOverride = (isCategory, objectId) =>
overridesByKey.value.get(overrideKey(Boolean(isCategory), objectId)) || {
is_category: Boolean(isCategory),
product_or_category_id: objectId,
percentage: 0,
fixed_price: null,
};
const getDiscountDisplay = (override) => (override.percentage > 0 ? `${override.percentage}%` : "-");
const getFixedPriceDisplay = (override) => (override.fixed_price !== null ? formatPrice(override.fixed_price) : "-");
const customerIdentifier = computed(() => ({ customerNumber: parsedCustomerNumber.value }));
const tilePresentation = computed(() => props.presentation === "superuser-tiles");
const requestLoad = () =>
props.scope === "limited"
? getLimitedBackofficeDepartmentCustomerPricing(departmentId.value, customerIdentifier.value)
: getSuperuserDepartmentCustomerPricing(departmentId.value, customerIdentifier.value);
const requestSave = (overrides) =>
props.scope === "limited"
? updateLimitedBackofficeDepartmentCustomerPricing(
departmentId.value,
customerIdentifier.value,
overrides,
pricingData.value?.revision ?? null
)
: updateSuperuserDepartmentCustomerPricing(
departmentId.value,
customerIdentifier.value,
overrides,
pricingData.value?.revision ?? null
);
const resetPricingData = () => {
pricingData.value = null;
errorMessage.value = "";
pricingConflict.value = false;
};
const loadPricing = async () => {
if (!canLoadPricing.value) {
resetPricingData();
return;
}
loading.value = true;
errorMessage.value = "";
pricingConflict.value = false;
try {
const response = await requestLoad();
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
} catch (error) {
pricingData.value = null;
errorMessage.value =
error?.response?.data?.data?.message ||
error?.response?.data?.message ||
error?.message ||
t("departments.customer_pricing.errors.load");
} finally {
loading.value = false;
}
};
const normalizedOverrides = (nextOverride) => {
const map = new Map(overridesByKey.value);
const key = overrideKey(nextOverride.is_category, nextOverride.product_or_category_id);
const percentage = Number.parseInt(String(nextOverride.percentage ?? 0), 10) || 0;
const fixedPrice =
nextOverride.fixed_price === null || nextOverride.fixed_price === undefined || nextOverride.fixed_price === ""
? null
: Number.parseInt(String(nextOverride.fixed_price), 10);
const normalized = {
is_category: Boolean(nextOverride.is_category),
product_or_category_id: nextOverride.product_or_category_id,
percentage: Math.min(100, Math.max(0, percentage)),
fixed_price: nextOverride.is_category ? null : fixedPrice,
};
if (normalized.percentage <= 0 && normalized.fixed_price === null) {
map.delete(key);
} else {
map.set(key, normalized);
}
return [...map.values()];
};
const saveOverride = async (nextOverride) => {
if (!props.canEdit || !canLoadPricing.value) {
return;
}
saving.value = true;
errorMessage.value = "";
pricingConflict.value = false;
try {
const response = await requestSave(normalizedOverrides(nextOverride));
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
} catch (error) {
const payload = error?.response?.data?.data || error?.response?.data || {};
pricingConflict.value = Number(error?.response?.status) === 409 && payload?.code === "pricing_revision_conflict";
errorMessage.value = pricingConflict.value
? t("templates.limited_backoffice.errors.pricing_conflict")
: payload?.message || error?.message || t("departments.customer_pricing.errors.save");
} finally {
saving.value = false;
}
};
const promptInteger = async ({ title, inputLabel, inputValue, allowEmpty = false, min = 0, max = null }) => {
const result = await Swal.fire({
title,
input: "number",
inputLabel,
inputValue,
inputAttributes: {
min,
...(max === null ? {} : { max }),
step: 1,
autocapitalize: "off",
},
showCancelButton: true,
confirmButtonText: t("common.save"),
showLoaderOnConfirm: true,
inputValidator: (value) => {
const normalized = String(value ?? "").trim();
if (allowEmpty && normalized === "") {
return null;
}
const parsed = Number.parseInt(normalized, 10);
if (!Number.isInteger(parsed) || parsed < min || (max !== null && parsed > max)) {
return max === null
? t("departments.customer_pricing.fixed_price_validation")
: t("departments.customer_pricing.discount_validation");
}
return null;
},
});
if (!result.isConfirmed) {
return undefined;
}
const normalized = String(result.value ?? "").trim();
return allowEmpty && normalized === "" ? null : Number.parseInt(normalized, 10);
};
const editDiscount = async (isCategory, objectId, label) => {
const override = getOverride(isCategory, objectId);
const value = await promptInteger({
title: label,
inputLabel: t("departments.customer_pricing.discount"),
inputValue: override.percentage > 0 ? override.percentage : "",
allowEmpty: true,
max: 100,
});
if (value === undefined) {
return;
}
await saveOverride({
...override,
percentage: value ?? 0,
});
};
const editFixedPrice = async (product) => {
const override = getOverride(false, product.id);
const value = await promptInteger({
title: product.name,
inputLabel: t("departments.customer_pricing.fixed_price"),
inputValue: override.fixed_price === null ? "" : override.fixed_price,
allowEmpty: true,
max: null,
});
if (value === undefined) {
return;
}
await saveOverride({
...override,
fixed_price: value,
});
};
watch(
() => props.initialCustomerNumber,
(value) => {
customerNumber.value = String(value || "");
if (canLoadPricing.value) {
void loadPricing();
}
},
{ immediate: true }
);
watch(
() => [props.departmentId, props.customPricingEnabled, props.canRead],
() => {
if (canLoadPricing.value) {
void loadPricing();
} else if (!props.customPricingEnabled || !props.canRead) {
resetPricingData();
}
}
);
</script>
<template>
<section
class="department-customer-pricing"
:class="{ 'department-customer-pricing--tiles': tilePresentation }"
data-testid="department-customer-pricing-editor"
>
<div
v-if="!props.canRead"
class="notification is-danger is-light"
data-testid="department-customer-pricing-forbidden"
>
{{ t("departments.customer_pricing.no_permission") }}
</div>
<div
v-else-if="!props.customPricingEnabled"
class="notification is-warning is-light"
data-testid="department-customer-pricing-disabled"
>
{{ t("departments.customer_pricing.not_enabled") }}
</div>
<template v-else>
<component
:is="tilePresentation ? SuperuserOverviewPanel : 'div'"
class="department-customer-pricing__toolbar-panel"
:title="tilePresentation ? t('departments.customer_pricing.title') : undefined"
:subtitle="tilePresentation ? t('departments.customer_pricing.open') : undefined"
data-testid="department-customer-pricing-lookup"
>
<div class="department-customer-pricing__toolbar">
<div class="field department-customer-pricing__customer-field">
<label class="label" for="department-customer-pricing-customer-number">
{{ t("departments.customer_pricing.customer_number") }}
</label>
<div class="field has-addons">
<div class="control is-expanded">
<input
id="department-customer-pricing-customer-number"
v-model="customerNumber"
class="input"
type="number"
min="1"
step="1"
inputmode="numeric"
:placeholder="t('departments.customer_pricing.customer_number')"
data-testid="department-customer-pricing-customer-number"
@keydown.enter.prevent="loadPricing"
/>
</div>
<div class="control">
<b-tooltip :label="t('departments.customer_pricing.load_customer')" position="is-bottom" type="is-dark">
<button
class="button is-info"
type="button"
:disabled="!canLoadPricing || loading"
data-testid="department-customer-pricing-load"
@click="loadPricing"
>
<span class="icon is-small">
<i class="fas" :class="loading ? 'fa-spinner fa-spin' : 'fa-search'" aria-hidden="true"></i>
</span>
</button>
</b-tooltip>
</div>
</div>
</div>
</div>
</component>
<div v-if="loading" class="notification is-light" data-testid="department-customer-pricing-loading">
{{ t("common.loading") }}
</div>
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-customer-pricing-error">
<span>{{ errorMessage }}</span>
<button
v-if="pricingConflict"
type="button"
class="button is-small is-danger is-light ml-3"
data-testid="department-customer-pricing-conflict-reload"
@click="loadPricing"
>
{{ t("templates.limited_backoffice.reload_pricing") }}
</button>
</div>
<div v-if="pricingData" class="department-customer-pricing__content">
<component
:is="tilePresentation ? SuperuserOverviewPanel : 'div'"
class="department-customer-pricing__summary-panel"
:title="tilePresentation ? customerDisplay : undefined"
:subtitle="tilePresentation ? pricingData.department?.name : undefined"
data-testid="department-customer-pricing-customer-panel"
>
<div class="level department-customer-pricing__summary">
<div class="level-left">
<div>
<h2 class="title is-5" data-testid="department-customer-pricing-customer">
{{ customerDisplay }}
</h2>
<p class="subtitle is-6">{{ pricingData.department?.name }}</p>
</div>
</div>
<div class="level-right">
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_global_discount')
: t('departments.customer_pricing.edit_disabled')
"
position="is-left"
type="is-dark"
>
<span>
<button
class="button is-small"
type="button"
:disabled="!props.canEdit || saving"
data-testid="department-customer-pricing-global-discount"
@click="editDiscount(true, 'global', t('departments.customer_pricing.global_discount'))"
>
<span class="icon is-small"><i class="fas fa-percent" aria-hidden="true"></i></span>
<span>{{ getDiscountDisplay(getOverride(true, "global")) }}</span>
</button>
</span>
</b-tooltip>
</div>
</div>
</component>
<div
v-if="saving"
class="notification is-light py-2"
role="status"
data-testid="department-customer-pricing-saving"
>
{{ t("departments.customer_pricing.saving") }}
</div>
<div v-if="!hasProducts" class="notification is-light">
{{ t("departments.customer_pricing.no_products") }}
</div>
<component
:is="tilePresentation ? SuperuserOverviewPanel : 'div'"
v-for="category in categories"
:key="category.id"
class="department-customer-pricing__category"
:title="tilePresentation ? category.name : undefined"
:subtitle="tilePresentation ? t('departments.customer_pricing.item_discount') : undefined"
>
<div class="department-customer-pricing__category-header">
<h3 class="title is-6">{{ category.name }}</h3>
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_category_discount')
: t('departments.customer_pricing.edit_disabled')
"
position="is-left"
type="is-dark"
>
<span>
<button
class="button is-small"
type="button"
:disabled="!props.canEdit || saving"
:data-testid="`department-customer-pricing-category-discount-${category.id}`"
@click="editDiscount(true, category.id, category.name)"
>
<span class="icon is-small"><i class="fas fa-tags" aria-hidden="true"></i></span>
<span>{{ getDiscountDisplay(getOverride(true, category.id)) }}</span>
</button>
</span>
</b-tooltip>
</div>
<div class="table-container">
<table class="table is-fullwidth is-hoverable is-striped department-customer-pricing__table">
<thead>
<tr>
<th>{{ t("common.product") }}</th>
<th>{{ t("departments.customer_pricing.department_price") }}</th>
<th>{{ t("departments.customer_pricing.fixed_price") }}</th>
<th>{{ t("departments.customer_pricing.item_discount") }}</th>
<th>{{ t("departments.customer_pricing.effective_price") }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="product in category.products"
:key="product.id"
:data-testid="`department-customer-pricing-product-${product.id}`"
>
<td>
<strong>{{ product.name }}</strong>
<p v-if="product.description" class="is-size-7 has-text-grey">{{ product.description }}</p>
</td>
<td :class="{ 'has-text-danger has-text-weight-semibold': product.missing_department_price }">
{{ formatPrice(product.department_price) }}
</td>
<td>
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_fixed_price')
: t('departments.customer_pricing.edit_disabled')
"
position="is-bottom"
type="is-dark"
>
<span>
<button
class="button is-small is-white department-customer-pricing__cell-button"
type="button"
:disabled="!props.canEdit || saving"
:data-testid="`department-customer-pricing-fixed-price-${product.id}`"
@click="editFixedPrice(product)"
>
<span>{{ getFixedPriceDisplay(getOverride(false, product.id)) }}</span>
<span class="icon is-small"><i class="fas fa-edit" aria-hidden="true"></i></span>
</button>
</span>
</b-tooltip>
</td>
<td>
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_item_discount')
: t('departments.customer_pricing.edit_disabled')
"
position="is-bottom"
type="is-dark"
>
<span>
<button
class="button is-small is-white department-customer-pricing__cell-button"
type="button"
:disabled="!props.canEdit || saving"
:data-testid="`department-customer-pricing-item-discount-${product.id}`"
@click="editDiscount(false, product.id, product.name)"
>
<span>{{ getDiscountDisplay(getOverride(false, product.id)) }}</span>
<span class="icon is-small"><i class="fas fa-edit" aria-hidden="true"></i></span>
</button>
</span>
</b-tooltip>
</td>
<td>{{ formatPrice(product.effective_price) }}</td>
</tr>
</tbody>
</table>
</div>
</component>
</div>
</template>
</section>
</template>
<style scoped>
.department-customer-pricing {
display: flex;
flex-direction: column;
gap: 1rem;
}
.department-customer-pricing__toolbar,
.department-customer-pricing__summary,
.department-customer-pricing__category-header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.department-customer-pricing__customer-field {
max-width: 420px;
width: 100%;
}
.department-customer-pricing__content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.department-customer-pricing__category {
margin-top: 0.5rem;
}
.department-customer-pricing--tiles .department-customer-pricing__category {
margin-top: 0;
}
.department-customer-pricing--tiles .department-customer-pricing__summary-panel .department-customer-pricing__summary,
.department-customer-pricing--tiles .department-customer-pricing__toolbar-panel .department-customer-pricing__toolbar {
margin-bottom: 0;
}
.department-customer-pricing--tiles .department-customer-pricing__category-header .title,
.department-customer-pricing--tiles .department-customer-pricing__summary .title,
.department-customer-pricing--tiles .department-customer-pricing__summary .subtitle {
display: none;
}
.department-customer-pricing__category-header {
align-items: center;
margin-bottom: 0.5rem;
}
.department-customer-pricing__category-header .title {
margin-bottom: 0;
}
.department-customer-pricing__table th,
.department-customer-pricing__table td {
vertical-align: middle;
}
.department-customer-pricing__cell-button {
justify-content: space-between;
min-width: 7rem;
width: 100%;
}
@media screen and (max-width: 768px) {
.department-customer-pricing__toolbar,
.department-customer-pricing__summary,
.department-customer-pricing__category-header {
align-items: stretch;
flex-direction: column;
}
}
</style>