Files
pleno-vue/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue
T

604 lines
22 KiB
Vue

<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import InvoiceDistributionCard from '@/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionCard.vue';
import InvoiceDistributionTrendChart from '@/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionTrendChart.vue';
import InvoiceDistributionSkeleton from '@/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionSkeleton.vue';
import {
fetchDistributionOverviewMonths,
fetchFirstOrderDate,
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionApi.js';
import {
buildSourceComposition,
getYearMonthLabel,
sortMonthSummaries,
toNumber,
} from '@/views/dashboards/superUserDashboard/invoiceDistribution/imports/invoiceDistributionCalculations.js';
const { t, locale } = useI18n();
const monthSummaries = ref([]);
const loading = ref(false);
const errorMessage = ref('');
const loadProgress = ref({ loaded: 0, total: 0 });
const search = ref('');
const yearFilter = ref('all');
const sortKey = ref('month');
const sortDirection = ref('desc');
const availableYears = computed(() => {
const years = new Set(monthSummaries.value.map((summary) => String(summary.year)));
return ['all', ...Array.from(years).sort((a, b) => Number(b) - Number(a))];
});
const filteredMonthSummaries = computed(() => {
const normalizedSearch = String(search.value || '').trim().toLowerCase();
return monthSummaries.value.filter((summary) => {
const monthLabel = getYearMonthLabel(summary.year, summary.month, locale.value);
const searchMatch = !normalizedSearch || monthLabel.toLowerCase().includes(normalizedSearch);
const yearMatch = yearFilter.value === 'all' || String(summary.year) === yearFilter.value;
return searchMatch && yearMatch;
});
});
const sortedMonthSummaries = computed(() => {
return sortMonthSummaries(filteredMonthSummaries.value, sortKey.value, sortDirection.value);
});
const totals = computed(() => {
return sortedMonthSummaries.value.reduce((acc, summary) => {
acc.bookedAmount += toNumber(summary.bookedAmount);
acc.distributionAmount += toNumber(summary.distributionAmount);
acc.fixedPricingAmount += toNumber(summary.fixedPricingAmount);
acc.subscriptionAmount += toNumber(summary.subscriptionAmount);
acc.customerPriceAmount += toNumber(summary.customerPriceAmount);
acc.totalAmount += toNumber(summary.totalAmount);
return acc;
}, {
bookedAmount: 0,
distributionAmount: 0,
fixedPricingAmount: 0,
subscriptionAmount: 0,
customerPriceAmount: 0,
totalAmount: 0,
});
});
const totalComposition = computed(() => buildSourceComposition(totals.value));
const monthsUsingLegacyDistribution = computed(() => {
return sortedMonthSummaries.value.filter((summary) => summary?.fallback?.usedLegacyDistribution);
});
const latestDistributionFallbackReason = computed(() => {
const last = monthsUsingLegacyDistribution.value[0];
return last?.fallback?.distributionFallbackReason || '';
});
const topMonths = computed(() => {
return sortMonthSummaries(sortedMonthSummaries.value, 'total', 'desc').slice(0, 3);
});
const formatCurrency = (value) => SessionUser.functions.currency.toLocal(toNumber(value));
const formatPercent = (value) => `${Math.round(toNumber(value))}%`;
const getMonthLabel = (summary) => {
return getYearMonthLabel(summary.year, summary.month, locale.value);
};
const getSortIcon = (column) => {
if (sortKey.value !== column) {
return 'fa-sort';
}
return sortDirection.value === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
};
const getAriaSort = (column) => {
if (sortKey.value !== column) {
return 'none';
}
return sortDirection.value === 'asc' ? 'ascending' : 'descending';
};
const setSort = (column) => {
if (sortKey.value === column) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
return;
}
sortKey.value = column;
sortDirection.value = 'desc';
};
const openMonthDetails = (summary) => {
window.open(`/superuser/invoices/distribution/${summary.year}/${summary.month}`, '_blank');
};
const resetFilters = () => {
search.value = '';
yearFilter.value = 'all';
sortKey.value = 'month';
sortDirection.value = 'desc';
};
const loadOverview = async () => {
loading.value = true;
errorMessage.value = '';
loadProgress.value = { loaded: 0, total: 0 };
try {
const firstOrderDate = await fetchFirstOrderDate();
const summaries = await fetchDistributionOverviewMonths({
fromDate: firstOrderDate,
onProgress: (progress) => {
loadProgress.value = progress;
},
});
monthSummaries.value = summaries;
} catch (error) {
errorMessage.value = SessionUser.functions.parseErrorMessage(error) || String(error?.message || error);
} finally {
loading.value = false;
}
};
onMounted(() => {
loadOverview();
});
</script>
<template>
<div class="distribution-overview distribution-overview-v2" data-testid="distribution-overview-page">
<InvoiceDistributionCard class="overview-header hero-surface">
<div class="overview-header__top hero-row">
<div>
<p class="eyebrow">Superuser Revenue Intelligence</p>
<h2 class="title is-4 mb-1">{{ t('superuser_invoice_distribution.overview.title') }}</h2>
<p class="subtitle is-6 mb-0">{{ t('superuser_invoice_distribution.overview.subtitle') }}</p>
</div>
<b-button
type="is-dark"
class="control-button"
:loading="loading"
:aria-label="t('superuser_invoice_distribution.actions.reload')"
@click="loadOverview"
icon-pack="fas"
icon-left="sync-alt"
data-testid="distribution-overview-refresh"
>
{{ t('superuser_invoice_distribution.actions.reload') }}
</b-button>
</div>
<div class="control-toolbar mt-4 overview-dock">
<b-field :label="t('superuser_invoice_distribution.filters.search')" class="toolbar-field">
<b-input
v-model="search"
:placeholder="t('superuser_invoice_distribution.filters.search_placeholder')"
icon="search"
icon-pack="fas"
:aria-label="t('superuser_invoice_distribution.filters.search')"
/>
</b-field>
<b-field :label="t('superuser_invoice_distribution.filters.year')" class="toolbar-field">
<b-select v-model="yearFilter" expanded :aria-label="t('superuser_invoice_distribution.filters.year')">
<option v-for="yearOption in availableYears" :key="yearOption" :value="yearOption">
{{ yearOption === 'all' ? t('superuser_invoice_distribution.filters.all_years') : yearOption }}
</option>
</b-select>
</b-field>
<div class="toolbar-hint" role="status" aria-live="polite">
<p class="toolbar-hint__title">{{ t('superuser_invoice_distribution.overview.loaded_months', { loaded: sortedMonthSummaries.length }) }}</p>
<p class="toolbar-hint__subtitle">{{ t('superuser_invoice_distribution.tooltips.summary') }}</p>
</div>
</div>
</InvoiceDistributionCard>
<b-message v-if="errorMessage" type="is-danger" has-icon icon-pack="fas" class="mb-0">
<div class="error-state__content">
<p>{{ t('superuser_invoice_distribution.errors.overview_load_failed') }}: {{ errorMessage }}</p>
<b-button type="is-danger" class="control-button is-light" @click="loadOverview">
{{ t('superuser_invoice_distribution.actions.try_again') }}
</b-button>
</div>
</b-message>
<b-message
v-if="!loading && monthsUsingLegacyDistribution.length"
type="is-warning"
has-icon
icon-pack="fas"
class="mb-0"
>
<div class="fallback-note" data-testid="distribution-overview-legacy-fallback-warning">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_fallback', { count: monthsUsingLegacyDistribution.length }) }}</p>
<p v-if="latestDistributionFallbackReason" class="is-size-7">{{ latestDistributionFallbackReason }}</p>
</div>
</b-message>
<InvoiceDistributionCard v-if="loading">
<div class="progress-meta mb-3">
<span>{{ t('superuser_invoice_distribution.loading.loading_months', { loaded: loadProgress.loaded, total: loadProgress.total }) }}</span>
</div>
<b-progress
type="is-primary"
size="is-small"
:value="loadProgress.loaded"
:max="Math.max(loadProgress.total, 1)"
aria-label="Loading distribution overview"
/>
<InvoiceDistributionSkeleton :rows="7" />
</InvoiceDistributionCard>
<template v-else>
<section class="kpi-strip" aria-label="KPI summary">
<InvoiceDistributionCard class="metric-card metric-total">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.total') }}</p>
<p class="metric-value">{{ formatCurrency(totals.totalAmount) }}</p>
</InvoiceDistributionCard>
<InvoiceDistributionCard class="metric-card metric-booked">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.booked') }}</p>
<p class="metric-value">{{ formatCurrency(totals.bookedAmount) }}</p>
</InvoiceDistributionCard>
<InvoiceDistributionCard class="metric-card metric-distribution">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.distribution') }}</p>
<p class="metric-value">{{ formatCurrency(totals.distributionAmount) }}</p>
</InvoiceDistributionCard>
<InvoiceDistributionCard class="metric-card metric-customer-prices">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</p>
<p class="metric-value">{{ formatCurrency(totals.customerPriceAmount) }}</p>
</InvoiceDistributionCard>
<InvoiceDistributionCard class="metric-card metric-months">
<p class="metric-title">{{ t('superuser_invoice_distribution.metrics.months') }}</p>
<p class="metric-value">{{ sortedMonthSummaries.length }}</p>
</InvoiceDistributionCard>
</section>
<InvoiceDistributionCard class="chart-section">
<div class="section-header">
<h3 class="title is-6 mb-0">{{ t('superuser_invoice_distribution.sections.trend') }}</h3>
</div>
<InvoiceDistributionTrendChart
:months="sortedMonthSummaries"
:locale="locale"
:aria-label="t('superuser_invoice_distribution.aria.trend_chart')"
:labels="{
total: t('superuser_invoice_distribution.metrics.total'),
booked: t('superuser_invoice_distribution.metrics.booked'),
distribution: t('superuser_invoice_distribution.metrics.distribution'),
customerPrices: t('superuser_invoice_distribution.metrics.customer_prices'),
}"
/>
</InvoiceDistributionCard>
<InvoiceDistributionCard>
<div class="section-header">
<h3 class="title is-6 mb-0">{{ t('superuser_invoice_distribution.sections.month_table') }}</h3>
<b-button size="is-small" type="is-light" class="control-button" @click="resetFilters">
{{ t('superuser_invoice_distribution.actions.reset_filters') }}
</b-button>
</div>
<div v-if="!sortedMonthSummaries.length" class="empty-state has-text-centered">
<p class="has-text-grey mb-3">{{ t('superuser_invoice_distribution.empty.no_months') }}</p>
<b-button type="is-link" class="control-button is-light" @click="loadOverview">
{{ t('common.retry') }}
</b-button>
</div>
<div class="table-container table-container--scroll" v-else>
<table class="table is-fullwidth is-hoverable is-striped distribution-table">
<caption>{{ t('superuser_invoice_distribution.aria.month_table_caption') }}</caption>
<thead>
<tr>
<th scope="col" :aria-sort="getAriaSort('month')">
<b-button
type="is-text"
size="is-small"
class="table-sort"
@click="setSort('month')"
:aria-label="t('superuser_invoice_distribution.aria.sort_month')"
>
<span>{{ t('superuser_invoice_distribution.table.month') }}</span>
<span class="icon is-small"><i class="fas" :class="getSortIcon('month')"></i></span>
</b-button>
</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.booked') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.fixed_pricing') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.subscriptions') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.customer_prices') }}</th>
<th scope="col" class="has-text-right">{{ t('superuser_invoice_distribution.table.distribution') }}</th>
<th scope="col" :aria-sort="getAriaSort('total')" class="has-text-right">
<b-button
type="is-text"
size="is-small"
class="table-sort table-sort--right"
@click="setSort('total')"
:aria-label="t('superuser_invoice_distribution.aria.sort_total')"
>
<span>{{ t('superuser_invoice_distribution.table.total') }}</span>
<span class="icon is-small"><i class="fas" :class="getSortIcon('total')"></i></span>
</b-button>
</th>
<th scope="col">{{ t('superuser_invoice_distribution.table.source_mix') }}</th>
<th scope="col">{{ t('superuser_invoice_distribution.table.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="summary in sortedMonthSummaries" :key="summary.key">
<th scope="row">{{ getMonthLabel(summary) }}</th>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.bookedAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.fixedPricingAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.subscriptionAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.customerPriceAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.distributionAmount) }}</td>
<td class="has-text-right is-family-monospace">{{ formatCurrency(summary.totalAmount) }}</td>
<td>
<div class="source-composition">
<div class="source-composition__bar" :aria-label="t('superuser_invoice_distribution.aria.source_mix_row', { month: getMonthLabel(summary) })">
<span
class="source-composition__segment source-composition__segment--fixed"
:style="{ width: `${buildSourceComposition(summary).fixedPercent}%` }"
></span>
<span
class="source-composition__segment source-composition__segment--subscription"
:style="{ width: `${buildSourceComposition(summary).subscriptionPercent}%` }"
></span>
<span
class="source-composition__segment source-composition__segment--customer-prices"
:style="{ width: `${buildSourceComposition(summary).customerPricePercent}%` }"
></span>
</div>
<span class="is-size-7 has-text-grey">
{{ formatPercent(buildSourceComposition(summary).fixedPercent) }}
/
{{ formatPercent(buildSourceComposition(summary).subscriptionPercent) }}
/
{{ formatPercent(buildSourceComposition(summary).customerPricePercent) }}
</span>
</div>
</td>
<td>
<b-button
size="is-small"
type="is-link"
class="control-button is-light"
@click="openMonthDetails(summary)"
:aria-label="t('superuser_invoice_distribution.aria.open_month', { month: getMonthLabel(summary) })"
icon-pack="fas"
icon-left="external-link-alt"
data-testid="distribution-overview-open-month"
>
{{ t('superuser_invoice_distribution.actions.open_month') }}
</b-button>
</td>
</tr>
</tbody>
</table>
</div>
</InvoiceDistributionCard>
<section class="insights-grid" aria-label="Secondary insights">
<InvoiceDistributionCard class="insight-card">
<h3 class="title is-6 mb-3">{{ t('superuser_invoice_distribution.sections.source_blend') }}</h3>
<div class="insight-row">
<span>{{ t('superuser_invoice_distribution.metrics.fixed_pricing') }}</span>
<strong>{{ formatCurrency(totalComposition.fixedAmount) }} ({{ formatPercent(totalComposition.fixedPercent) }})</strong>
</div>
<div class="insight-row">
<span>{{ t('superuser_invoice_distribution.metrics.subscriptions') }}</span>
<strong>{{ formatCurrency(totalComposition.subscriptionAmount) }} ({{ formatPercent(totalComposition.subscriptionPercent) }})</strong>
</div>
<div class="insight-row">
<span>{{ t('superuser_invoice_distribution.metrics.customer_prices') }}</span>
<strong>{{ formatCurrency(totalComposition.customerPriceAmount) }} ({{ formatPercent(totalComposition.customerPricePercent) }})</strong>
</div>
</InvoiceDistributionCard>
<InvoiceDistributionCard class="insight-card">
<h3 class="title is-6 mb-3">{{ t('superuser_invoice_distribution.sections.top_months') }}</h3>
<div v-if="topMonths.length" class="top-month-list">
<b-button
v-for="summary in topMonths"
:key="summary.key"
size="is-small"
type="is-light"
class="top-month-button control-button"
@click="openMonthDetails(summary)"
>
<span>{{ getMonthLabel(summary) }}</span>
<span class="has-text-weight-semibold">{{ formatCurrency(summary.totalAmount) }}</span>
</b-button>
</div>
<p v-else class="has-text-grey">{{ t('superuser_invoice_distribution.empty.no_months') }}</p>
</InvoiceDistributionCard>
</section>
</template>
</div>
</template>
<style scoped>
:root {
--distribution-v2-accent: #0f6b86;
}
:deep(.card) {
border-radius: 16px;
}
.distribution-overview-v2 {
display: grid;
gap: 1rem;
}
.hero-surface {
background:
radial-gradient(circle at top right, rgba(15, 107, 134, 0.2), transparent 55%),
linear-gradient(135deg, #f5fbff 0%, #f7f8fc 100%);
}
.hero-row {
align-items: flex-start;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
font-size: 0.7rem;
margin-bottom: 0.4rem;
color: #38576a;
}
.overview-dock {
display: grid;
grid-template-columns: 1fr 1fr minmax(220px, 1fr);
gap: 0.9rem;
align-items: end;
}
.toolbar-hint {
padding: 0.75rem 0.9rem;
background: #ffffff;
border: 1px solid #d8e5ee;
border-radius: 12px;
}
.toolbar-hint__title {
font-weight: 600;
}
.toolbar-hint__subtitle {
color: #617986;
font-size: 0.82rem;
}
.fallback-note p + p {
margin-top: 0.3rem;
}
.kpi-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.8rem;
}
.metric-card {
background: #ffffff;
border: 1px solid #d8e5ee;
}
.metric-title {
color: #617986;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.metric-value {
font-size: 1.3rem;
font-weight: 700;
}
.metric-customer-prices {
border-color: #f0d6c8;
background: #fffaf7;
}
.chart-section {
border: 1px solid #d8e5ee;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.distribution-table {
font-size: 0.92rem;
}
.source-composition__bar {
width: 100%;
max-width: 160px;
height: 8px;
border-radius: 999px;
overflow: hidden;
background: #dde8ef;
display: flex;
}
.source-composition__segment {
display: inline-block;
height: 100%;
}
.source-composition__segment--fixed {
background: #1f77b4;
}
.source-composition__segment--subscription {
background: #ff7f0e;
}
.source-composition__segment--customer-prices {
background: #c44536;
}
.insights-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.insight-card {
border: 1px solid #d8e5ee;
}
.insight-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 0.35rem 0;
}
.top-month-list {
display: grid;
gap: 0.45rem;
}
.top-month-button {
justify-content: space-between;
}
@media (max-width: 1100px) {
.overview-dock {
grid-template-columns: 1fr;
}
.kpi-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.kpi-strip {
grid-template-columns: 1fr;
}
.insights-grid {
grid-template-columns: 1fr;
}
}
:deep(.button:focus-visible),
:deep(.input:focus-visible),
:deep(.select select:focus-visible),
:deep(.textarea:focus-visible) {
outline: 2px solid rgba(15, 107, 134, 0.55);
outline-offset: 2px;
}
</style>