feat(invoicing): add ProductAggregatesCard for superuser fakturaer view (TRU-185)

This commit is contained in:
Frontend Agent
2026-08-17 16:40:03 +00:00
parent 4637cb867b
commit 61a27a0e8e
3 changed files with 470 additions and 0 deletions
@@ -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>
@@ -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) =>
@@ -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"