From 61d00cceef9501da159fe5c9a2c495c891da2657 Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Thu, 30 Jul 2026 09:26:52 +0200 Subject: [PATCH 01/13] fix(ui): fix Company field and System filter --- .../applications/AssignOrganizationDrawer.vue | 1 + .../OrganizationApplicationsCard.vue | 10 ++- .../OrganizationCombobox.test.ts | 82 +++++++++++++++++++ .../organizations/OrganizationCombobox.vue | 20 +++++ .../organizations/OrganizationSystemsCard.vue | 7 +- .../systems/CreateOrEditSystemDrawer.vue | 1 + .../systems/SystemApplicationsCard.vue | 8 +- .../users/CreateOrEditUserDrawer.vue | 1 + frontend/src/views/CustomerDetailView.vue | 3 +- frontend/src/views/DistributorDetailView.vue | 2 + frontend/src/views/ResellerDetailView.vue | 2 + 11 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/organizations/OrganizationCombobox.test.ts diff --git a/frontend/src/components/applications/AssignOrganizationDrawer.vue b/frontend/src/components/applications/AssignOrganizationDrawer.vue index 6c552ab8a..aff98fb24 100644 --- a/frontend/src/components/applications/AssignOrganizationDrawer.vue +++ b/frontend/src/components/applications/AssignOrganizationDrawer.vue @@ -127,6 +127,7 @@ async function saveApplication() { () const { t } = useI18n() @@ -48,7 +49,11 @@ const moreSystems = computed(() => { const goToSystems = () => { const companyId = route.params.companyId as string - organizationFilterForSystems.value = companyId ? [{ id: companyId, label: companyId }] : [] + // the filter renders the label carried by the selection: pass the organization + // name, as it may not be among the options the dropdown has loaded + organizationFilterForSystems.value = companyId + ? [{ id: companyId, label: props.organizationName || companyId }] + : [] router.push({ name: 'systems' }) } diff --git a/frontend/src/components/systems/CreateOrEditSystemDrawer.vue b/frontend/src/components/systems/CreateOrEditSystemDrawer.vue index c99ab7f84..d9bd6044b 100644 --- a/frontend/src/components/systems/CreateOrEditSystemDrawer.vue +++ b/frontend/src/components/systems/CreateOrEditSystemDrawer.vue @@ -335,6 +335,7 @@ function copySecretAndCloseDrawer() { ref="organizationIdRef" v-model="organizationId" :is-shown="isShown" + :selected-organization="currentSystem?.organization" :label="$t('systems.organization')" :helper-text="$t('systems.organization_helper')" :invalid-message=" diff --git a/frontend/src/components/systems/SystemApplicationsCard.vue b/frontend/src/components/systems/SystemApplicationsCard.vue index de47d47f4..a070be5e1 100644 --- a/frontend/src/components/systems/SystemApplicationsCard.vue +++ b/frontend/src/components/systems/SystemApplicationsCard.vue @@ -16,11 +16,13 @@ import router from '@/router' import { useRoute } from 'vue-router' import { useApplications } from '@/queries/applications/applications' import { useApplicationsSummaryBySystem } from '@/queries/applications/applicationsSummaryBySystem' +import { useSystemDetail } from '@/queries/systems/systemDetail' const { t } = useI18n() const route = useRoute() const { state: applicationsSummary } = useApplicationsSummaryBySystem() +const { state: systemDetail } = useSystemDetail() const { systemFilter: systemFilterForApps, clearFilters: clearApplicationsFilters } = useApplications() @@ -45,7 +47,11 @@ const moreApplications = computed(() => { const goToApplications = () => { const systemId = route.params.systemId as string clearApplicationsFilters() - systemFilterForApps.value = systemId ? [{ id: systemId, label: systemId }] : [] + // the filter renders the label carried by the selection: pass the system name, + // as the selected system may not be among the options the dropdown has loaded + systemFilterForApps.value = systemId + ? [{ id: systemId, label: systemDetail.value.data?.name || systemId }] + : [] router.push({ name: 'applications' }) } diff --git a/frontend/src/components/users/CreateOrEditUserDrawer.vue b/frontend/src/components/users/CreateOrEditUserDrawer.vue index 5e0aba2dc..e5147d739 100644 --- a/frontend/src/components/users/CreateOrEditUserDrawer.vue +++ b/frontend/src/components/users/CreateOrEditUserDrawer.vue @@ -354,6 +354,7 @@ function getEmailInvalidMessage(): string { ref="organizationIdRef" v-model="organizationId" :is-shown="isShown" + :selected-organization="currentUser?.organization" :label="$t('users.organization')" :invalid-message=" validationIssues.organization_id?.[0] ? $t(validationIssues.organization_id[0]) : '' diff --git a/frontend/src/views/CustomerDetailView.vue b/frontend/src/views/CustomerDetailView.vue index f58148843..629971925 100644 --- a/frontend/src/views/CustomerDetailView.vue +++ b/frontend/src/views/CustomerDetailView.vue @@ -51,9 +51,10 @@ const { state: customerSystems } = useCustomerSystems() :systems-status="customerSystems.status" :systems-data="customerSystems.data" :stats-status="customerStats.status" + :organization-name="customerDetail.data?.name" /> - + diff --git a/frontend/src/views/DistributorDetailView.vue b/frontend/src/views/DistributorDetailView.vue index def700b61..66288acb1 100644 --- a/frontend/src/views/DistributorDetailView.vue +++ b/frontend/src/views/DistributorDetailView.vue @@ -94,12 +94,14 @@ const hierarchySystemsRoute = computed(() => { :systems-status="distributorSystems.status" :systems-data="distributorSystems.data" :stats-status="distributorStats.status" + :organization-name="distributorDetail.data?.name" /> diff --git a/frontend/src/views/ResellerDetailView.vue b/frontend/src/views/ResellerDetailView.vue index 86f72117b..0776c1e65 100644 --- a/frontend/src/views/ResellerDetailView.vue +++ b/frontend/src/views/ResellerDetailView.vue @@ -94,12 +94,14 @@ const hierarchySystemsRoute = computed(() => { :systems-status="resellerSystems.status" :systems-data="resellerSystems.data" :stats-status="resellerStats.status" + :organization-name="resellerDetail.data?.name" /> From 4e3f5c167cb6de434ccfd87079038160375c8a7d Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Thu, 30 Jul 2026 10:21:48 +0200 Subject: [PATCH 02/13] fix(ui): table ids and add catch to route.push() --- .../components/account/impersonation/SessionModal.vue | 2 +- .../components/account/impersonation/SessionsTable.vue | 2 +- .../src/components/applications/ApplicationsTable.vue | 10 ++++++++-- frontend/src/components/customers/CustomersTable.vue | 10 ++++++++-- .../src/components/distributors/DistributorsTable.vue | 10 ++++++++-- frontend/src/components/resellers/ResellersTable.vue | 10 ++++++++-- frontend/src/components/systems/SystemsTable.vue | 8 ++++++-- frontend/src/components/users/UsersTable.vue | 2 +- 8 files changed, 41 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/account/impersonation/SessionModal.vue b/frontend/src/components/account/impersonation/SessionModal.vue index 528951b0d..3b5c047c0 100644 --- a/frontend/src/components/account/impersonation/SessionModal.vue +++ b/frontend/src/components/account/impersonation/SessionModal.vue @@ -152,7 +152,7 @@ const copyRequestDataToClipboard = (jsonString: string) => { {{ $t('account.impersonation.response_status') }} - + {{ item.timestamp ? formatDateTime(new Date(item.timestamp), locale) : '-' }} diff --git a/frontend/src/components/account/impersonation/SessionsTable.vue b/frontend/src/components/account/impersonation/SessionsTable.vue index 278be9472..2029612d2 100644 --- a/frontend/src/components/account/impersonation/SessionsTable.vue +++ b/frontend/src/components/account/impersonation/SessionsTable.vue @@ -96,7 +96,7 @@ const onSessionModalClose = () => { - + {{ item.start_time ? formatDateTimeNoSeconds(new Date(item.start_time), locale) : '-' diff --git a/frontend/src/components/applications/ApplicationsTable.vue b/frontend/src/components/applications/ApplicationsTable.vue index 39a2972de..701938553 100644 --- a/frontend/src/components/applications/ApplicationsTable.vue +++ b/frontend/src/components/applications/ApplicationsTable.vue @@ -179,7 +179,13 @@ const onSort = (payload: SortEvent) => { } const goToApplicationDetails = (application: Application) => { - router.push({ name: 'application_detail', params: { applicationId: application.id } }) + router + .push({ name: 'application_detail', params: { applicationId: application.id } }) + .catch((error) => { + // router.push() swallows navigation failures by default; log them so an + // intermittent "URL changes but the page doesn't" report leaves a trace + console.error('[goToApplicationDetails]', error) + }) } @@ -318,7 +324,7 @@ const goToApplicationDetails = (application: Application) => { - + { } const goToCustomerDetails = (customer: Customer) => { - router.push({ name: 'customer_detail', params: { companyId: customer.logto_id } }) + router + .push({ name: 'customer_detail', params: { companyId: customer.logto_id } }) + .catch((error) => { + // router.push() swallows navigation failures by default; log them so an + // intermittent "URL changes but the page doesn't" report leaves a trace + console.error('[goToCustomerDetails]', error) + }) } @@ -395,7 +401,7 @@ const goToCustomerDetails = (customer: Customer) => { - + { } const goToDistributorDetails = (distributor: Distributor) => { - router.push({ name: 'distributor_detail', params: { companyId: distributor.logto_id } }) + router + .push({ name: 'distributor_detail', params: { companyId: distributor.logto_id } }) + .catch((error) => { + // router.push() swallows navigation failures by default; log them so an + // intermittent "URL changes but the page doesn't" report leaves a trace + console.error('[goToDistributorDetails]', error) + }) } @@ -403,7 +409,7 @@ const goToDistributorDetails = (distributor: Distributor) => { - + { } const goToResellerDetails = (reseller: Reseller) => { - router.push({ name: 'reseller_detail', params: { companyId: reseller.logto_id } }) + router + .push({ name: 'reseller_detail', params: { companyId: reseller.logto_id } }) + .catch((error) => { + // router.push() swallows navigation failures by default; log them so an + // intermittent "URL changes but the page doesn't" report leaves a trace + console.error('[goToResellerDetails]', error) + }) } @@ -417,7 +423,7 @@ const goToResellerDetails = (reseller: Reseller) => { - + { } const goToSystemDetails = (system: System) => { - router.push({ name: 'system_detail', params: { systemId: system.id } }) + router.push({ name: 'system_detail', params: { systemId: system.id } }).catch((error) => { + // router.push() swallows navigation failures by default; log them so an + // intermittent "URL changes but the page doesn't" report leaves a trace + console.error('[goToSystemDetails]', error) + }) } function onSecretRegenerated(secret: string) { @@ -536,7 +540,7 @@ function onCloseSecretRegeneratedModal() { - +
{ - +
Date: Thu, 30 Jul 2026 10:23:01 +0200 Subject: [PATCH 03/13] fix(ui): make system apps counter clickable --- frontend/src/components/systems/SystemApplicationsCard.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/systems/SystemApplicationsCard.vue b/frontend/src/components/systems/SystemApplicationsCard.vue index a070be5e1..46418086f 100644 --- a/frontend/src/components/systems/SystemApplicationsCard.vue +++ b/frontend/src/components/systems/SystemApplicationsCard.vue @@ -63,6 +63,7 @@ const goToApplications = () => { :icon="faGridOne" :loading="applicationsSummary.status === 'pending'" :centeredCounter="!applicationsCount" + @counter-click="goToApplications" >
Date: Thu, 30 Jul 2026 10:26:10 +0200 Subject: [PATCH 04/13] fix(ui): italian tralations --- frontend/src/i18n/it/translation.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json index 8db8e2d70..983e627a0 100644 --- a/frontend/src/i18n/it/translation.json +++ b/frontend/src/i18n/it/translation.json @@ -25,8 +25,8 @@ "copied": "Copiato!", "status": "Stato", "any": "Qualsiasi", - "clear_filter": "Cancella filtro", - "clear_filters": "Cancella filtri", + "clear_filter": "Azzera filtro", + "clear_filters": "Azzera filtri", "reset_filters": "Reimposta filtri", "try_changing_search_filters": "Prova a modificare i filtri di ricerca", "go_to_page": "Vai a {page}", @@ -756,7 +756,7 @@ "no_options": "Nessuna opzione", "more_options_hidden": "Continua a digitare per visualizzare altre opzioni", "options_filter_placeholder": "Filtra opzioni", - "clear_selection": "Cancella selezione", + "clear_selection": "Azzera selezione", "reset_selection": "Reimposta selezione", "clear_search": "Azzera ricerca" }, From d7d215e8810061f5e14943fec5fc1c7edf5a54db Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Thu, 30 Jul 2026 18:39:40 +0200 Subject: [PATCH 05/13] fix(ui): add parent company filter --- .../components/customers/CustomersTable.vue | 8 +++++ .../distributors/DistributorsTable.vue | 22 +++++++++++++- .../OrganizationDropdownFilter.vue | 11 ++++++- .../components/resellers/ResellersTable.vue | 30 ++++++++++++++++++- .../src/composables/useOrganizationFilter.ts | 11 +++++-- frontend/src/i18n/en/translation.json | 5 +++- frontend/src/i18n/it/translation.json | 7 +++-- frontend/src/lib/organizations/customers.ts | 10 +++++++ frontend/src/lib/organizations/resellers.ts | 10 +++++++ .../lib/organizations/searchOrganizations.ts | 12 +++++++- .../src/queries/organizations/customers.ts | 18 ++++++++++- .../src/queries/organizations/resellers.ts | 18 ++++++++++- frontend/src/views/CustomersView.vue | 24 ++++++++++++++- frontend/src/views/DistributorDetailView.vue | 18 +++++++++++ frontend/src/views/ResellerDetailView.vue | 18 +++++++++++ frontend/src/views/ResellersView.vue | 24 ++++++++++++++- 16 files changed, 232 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/customers/CustomersTable.vue b/frontend/src/components/customers/CustomersTable.vue index 084c4fbf9..c5f8036ed 100644 --- a/frontend/src/components/customers/CustomersTable.vue +++ b/frontend/src/components/customers/CustomersTable.vue @@ -54,6 +54,7 @@ import { useCustomers } from '@/queries/organizations/customers' import { canManageCustomers, canDestroyCustomers } from '@/lib/permissions' import router from '@/router' import UpdatingSpinner from '@/components/common/UpdatingSpinner.vue' +import OrganizationDropdownFilter from '@/components/organizations/OrganizationDropdownFilter.vue' const { isShownCreateCustomerDrawer = false } = defineProps<{ isShownCreateCustomerDrawer: boolean @@ -70,6 +71,7 @@ const { textFilter, statusFilter, createdByFilter, + organizationFilter, sortBy, sortDescending, areDefaultFiltersApplied, @@ -318,6 +320,12 @@ const goToCustomerDetails = (customer: Customer) => { :custom-action-label="t('ne_dropdown_filter.reset_selection')" @custom-action="resetStatusFilter" /> + + { {{ item.custom_data?.vat || '-' }} -
+ + + +
() const emit = defineEmits<{ @@ -26,7 +32,10 @@ const emit = defineEmits<{ const { t } = useI18n() const computedLabel = label ?? t('organizations.organization') -const { options, loading, onSearch, currentSearch } = useOrganizationFilter() +const { options, loading, onSearch, currentSearch } = useOrganizationFilter( + undefined, + () => organizationType, +) const noCompanyLabel = computed(() => t('organizations.no_company')) diff --git a/frontend/src/components/resellers/ResellersTable.vue b/frontend/src/components/resellers/ResellersTable.vue index 468a0be84..ef3f3fe10 100644 --- a/frontend/src/components/resellers/ResellersTable.vue +++ b/frontend/src/components/resellers/ResellersTable.vue @@ -57,6 +57,7 @@ import { useResellers } from '@/queries/organizations/resellers' import { canManageResellers, canDestroyResellers, canPromoteOrganizations } from '@/lib/permissions' import router from '@/router' import UpdatingSpinner from '@/components/common/UpdatingSpinner.vue' +import OrganizationDropdownFilter from '@/components/organizations/OrganizationDropdownFilter.vue' const { isShownCreateResellerDrawer = false } = defineProps<{ isShownCreateResellerDrawer: boolean @@ -73,6 +74,7 @@ const { textFilter, statusFilter, createdByFilter, + organizationFilter, sortBy, sortDescending, areDefaultFiltersApplied, @@ -339,6 +341,12 @@ const goToResellerDetails = (reseller: Reseller) => { :options-filter-placeholder="t('ne_dropdown_filter.options_filter_placeholder')" @custom-action="resetStatusFilter" /> + + { {{ item.custom_data?.vat || '-' }} -
+ + + +
) { +export function useOrganizationFilter( + enabled?: MaybeRefOrGetter, + type?: MaybeRefOrGetter, +) { const { t } = useI18n() const loginStore = useLoginStore() const searchInput = ref('') @@ -32,9 +37,9 @@ export function useOrganizationFilter(enabled?: MaybeRefOrGetter) { ) const { state, asyncStatus } = useQuery({ - key: () => [ORGANIZATIONS_SEARCH_KEY, debouncedSearch.value], + key: () => [ORGANIZATIONS_SEARCH_KEY, debouncedSearch.value, toValue(type) ?? ''], enabled: () => !!loginStore.jwtToken && toValue(enabled ?? true), - query: () => searchOrganizations(debouncedSearch.value), + query: () => searchOrganizations(debouncedSearch.value, toValue(type)), }) const organizations = computed(() => state.value.data ?? []) diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 98f343e20..e842d022a 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -123,7 +123,8 @@ "total_customers": "Total customers", "import_distributors": "Import distributors", "distributors_imported": "Distributors imported", - "show_distributor_systems": "Show systems of {name} and its hierarchy" + "show_distributor_systems": "Show systems of {name} and its hierarchy", + "show_distributor_resellers": "Show resellers of {name}" }, "distributor_detail": { "title": "Distributor detail", @@ -162,6 +163,7 @@ "import_resellers": "Import resellers", "resellers_imported": "Resellers imported", "show_reseller_systems": "Show systems of {name} and its hierarchy", + "show_reseller_customers": "Show customers of {name}", "reseller_promoted": "Reseller promoted", "reseller_promoted_successfully": "{name} is now a distributor", "promote_reseller": "Promote to distributor", @@ -216,6 +218,7 @@ "custom_data_email_invalid": "Invalid email address", "custom_data_phone_invalid_format": "Invalid phone number", "organization": "Company", + "parent_company": "Parent company", "no_company": "No company", "choose_organization": "Choose company", "no_organizations": "No companies", diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json index 983e627a0..28f397459 100644 --- a/frontend/src/i18n/it/translation.json +++ b/frontend/src/i18n/it/translation.json @@ -122,7 +122,8 @@ "total_customers": "Clienti totali", "import_distributors": "Importa distributori", "distributors_imported": "Distributori importati", - "show_distributor_systems": "Mostra i sistemi di {name} e della sua gerarchia" + "show_distributor_systems": "Mostra i sistemi di {name} e della sua gerarchia", + "show_distributor_resellers": "Mostra i rivenditori di {name}" }, "distributor_detail": { "title": "Dettaglio distributore", @@ -160,7 +161,8 @@ "export_resellers_to_csv": "Esporta rivenditori in CSV", "import_resellers": "Importa rivenditori", "resellers_imported": "Rivenditori importati", - "show_reseller_systems": "Mostra i sistemi di {name} e della sua gerarchia" + "show_reseller_systems": "Mostra i sistemi di {name} e della sua gerarchia", + "show_reseller_customers": "Mostra i clienti di {name}" }, "customers": { "title": "Clienti", @@ -207,6 +209,7 @@ "custom_data_email_invalid": "Indirizzo email non valido", "custom_data_phone_invalid_format": "Numero di telefono non valido", "organization": "Azienda", + "parent_company": "Azienda di appartenenza", "choose_organization": "Scegli azienda", "no_organizations": "Nessuna azienda", "email": "Email", diff --git a/frontend/src/lib/organizations/customers.ts b/frontend/src/lib/organizations/customers.ts index 85ba0ad32..3b8a6a885 100644 --- a/frontend/src/lib/organizations/customers.ts +++ b/frontend/src/lib/organizations/customers.ts @@ -92,6 +92,7 @@ export const getQueryStringParams = ( textFilter: string | null, statusFilter: CustomerStatus[], createdByFilter: string[], + organizationFilter: string[], sortBy: string | null, sortDescending: boolean, ) => { @@ -114,6 +115,13 @@ export const getQueryStringParams = ( searchParams.append('created_by', userId) }) + // Parent company filter: matched exactly against the customer's owning + // organization. include_hierarchy is deliberately not sent, so selecting a + // reseller never pulls in the customers of its descendants. + organizationFilter.forEach((organizationId) => { + searchParams.append('organization_id', organizationId) + }) + return searchParams.toString() } @@ -123,6 +131,7 @@ export const getCustomers = ( textFilter: string, statusFilter: CustomerStatus[], createdByFilter: string[], + organizationFilter: string[], sortBy: string, sortDescending: boolean, ) => { @@ -133,6 +142,7 @@ export const getCustomers = ( textFilter, statusFilter, createdByFilter, + organizationFilter, sortBy, sortDescending, ) diff --git a/frontend/src/lib/organizations/resellers.ts b/frontend/src/lib/organizations/resellers.ts index ea2be05f2..87a44f414 100644 --- a/frontend/src/lib/organizations/resellers.ts +++ b/frontend/src/lib/organizations/resellers.ts @@ -92,6 +92,7 @@ export const getQueryStringParams = ( textFilter: string | null, statusFilter: ResellerStatus[], createdByFilter: string[], + organizationFilter: string[], sortBy: string | null, sortDescending: boolean, ) => { @@ -114,6 +115,13 @@ export const getQueryStringParams = ( searchParams.append('created_by', userId) }) + // Parent company filter: matched exactly against the reseller's owning + // organization. include_hierarchy is deliberately not sent, so selecting a + // distributor never pulls in the resellers of its descendants. + organizationFilter.forEach((organizationId) => { + searchParams.append('organization_id', organizationId) + }) + return searchParams.toString() } @@ -123,6 +131,7 @@ export const getResellers = ( textFilter: string, statusFilter: ResellerStatus[], createdByFilter: string[], + organizationFilter: string[], sortBy: string, sortDescending: boolean, ) => { @@ -133,6 +142,7 @@ export const getResellers = ( textFilter, statusFilter, createdByFilter, + organizationFilter, sortBy, sortDescending, ) diff --git a/frontend/src/lib/organizations/searchOrganizations.ts b/frontend/src/lib/organizations/searchOrganizations.ts index f30f345d5..957442487 100644 --- a/frontend/src/lib/organizations/searchOrganizations.ts +++ b/frontend/src/lib/organizations/searchOrganizations.ts @@ -23,7 +23,13 @@ interface OrganizationsSearchResponse { } } -export const searchOrganizations = (search: string) => { +/** + * @param type restricts the results to a single company type (distributor, + * reseller, customer). Scoping server-side keeps the paginated option list + * meaningful: filtering by type on the client would drop entries from an + * already-truncated page. + */ +export const searchOrganizations = (search: string, type?: string) => { const loginStore = useLoginStore() const params = new URLSearchParams({ page: '1', @@ -34,6 +40,10 @@ export const searchOrganizations = (search: string) => { params.append('search', search) } + if (type) { + params.append('type', type) + } + return axios .get(`${API_URL}/organizations?${params}`, { headers: { Authorization: `Bearer ${loginStore.jwtToken}` }, diff --git a/frontend/src/queries/organizations/customers.ts b/frontend/src/queries/organizations/customers.ts index 76ba01de3..84471e059 100644 --- a/frontend/src/queries/organizations/customers.ts +++ b/frontend/src/queries/organizations/customers.ts @@ -27,6 +27,8 @@ export const useCustomers = defineQuery(() => { { id: 'suspended', label: 'suspended' }, ]) const createdByFilter = ref([]) + // parent company: the reseller or distributor the customer belongs to + const organizationFilter = ref([]) const sortBy = ref('name') const sortDescending = ref(false) @@ -39,6 +41,7 @@ export const useCustomers = defineQuery(() => { textFilter: debouncedTextFilter.value, statusFilter: statusFilter.value.map((o) => o.id), createdByFilter: createdByFilter.value.map((o) => o.id), + organizationFilter: organizationFilter.value.map((o) => o.id), sortBy: sortBy.value, sortDirection: sortDescending.value, }, @@ -51,6 +54,7 @@ export const useCustomers = defineQuery(() => { debouncedTextFilter.value, statusFilter.value.map((o) => o.id) as CustomerStatus[], createdByFilter.value.map((o) => o.id), + organizationFilter.value.map((o) => o.id), sortBy.value, sortDescending.value, ), @@ -63,7 +67,8 @@ export const useCustomers = defineQuery(() => { statusFilter.value.some((o) => o.id === 'enabled') && statusFilter.value.some((o) => o.id === 'suspended') && !statusFilter.value.some((o) => o.id === 'deleted') && - createdByFilter.value.length === 0 + createdByFilter.value.length === 0 && + organizationFilter.value.length === 0 ) }) @@ -116,9 +121,19 @@ export const useCustomers = defineQuery(() => { { deep: true }, ) + // reset to first page when parent company filter changes + watch( + () => organizationFilter.value, + () => { + pageNum.value = 1 + }, + { deep: true }, + ) + const resetFilters = () => { textFilter.value = '' createdByFilter.value = [] + organizationFilter.value = [] resetStatusFilter() } @@ -139,6 +154,7 @@ export const useCustomers = defineQuery(() => { debouncedTextFilter, statusFilter, createdByFilter, + organizationFilter, sortBy, sortDescending, areDefaultFiltersApplied, diff --git a/frontend/src/queries/organizations/resellers.ts b/frontend/src/queries/organizations/resellers.ts index 366197fa4..ac60b5e5b 100644 --- a/frontend/src/queries/organizations/resellers.ts +++ b/frontend/src/queries/organizations/resellers.ts @@ -27,6 +27,8 @@ export const useResellers = defineQuery(() => { { id: 'suspended', label: 'suspended' }, ]) const createdByFilter = ref([]) + // parent company: the distributor the reseller belongs to + const organizationFilter = ref([]) const sortBy = ref('name') const sortDescending = ref(false) @@ -39,6 +41,7 @@ export const useResellers = defineQuery(() => { textFilter: debouncedTextFilter.value, statusFilter: statusFilter.value.map((o) => o.id), createdByFilter: createdByFilter.value.map((o) => o.id), + organizationFilter: organizationFilter.value.map((o) => o.id), sortBy: sortBy.value, sortDirection: sortDescending.value, }, @@ -51,6 +54,7 @@ export const useResellers = defineQuery(() => { debouncedTextFilter.value, statusFilter.value.map((o) => o.id) as ResellerStatus[], createdByFilter.value.map((o) => o.id), + organizationFilter.value.map((o) => o.id), sortBy.value, sortDescending.value, ), @@ -63,7 +67,8 @@ export const useResellers = defineQuery(() => { statusFilter.value.some((o) => o.id === 'enabled') && statusFilter.value.some((o) => o.id === 'suspended') && !statusFilter.value.some((o) => o.id === 'deleted') && - createdByFilter.value.length === 0 + createdByFilter.value.length === 0 && + organizationFilter.value.length === 0 ) }) @@ -117,6 +122,15 @@ export const useResellers = defineQuery(() => { { deep: true }, ) + // reset to first page when parent company filter changes + watch( + () => organizationFilter.value, + () => { + pageNum.value = 1 + }, + { deep: true }, + ) + // reset to first page when sorting changes watch( () => [sortBy.value, sortDescending.value], @@ -128,6 +142,7 @@ export const useResellers = defineQuery(() => { const resetFilters = () => { textFilter.value = '' createdByFilter.value = [] + organizationFilter.value = [] resetStatusFilter() } @@ -148,6 +163,7 @@ export const useResellers = defineQuery(() => { debouncedTextFilter, statusFilter, createdByFilter, + organizationFilter, sortBy, sortDescending, areDefaultFiltersApplied, diff --git a/frontend/src/views/CustomersView.vue b/frontend/src/views/CustomersView.vue index 45f0f0b54..39f08091a 100644 --- a/frontend/src/views/CustomersView.vue +++ b/frontend/src/views/CustomersView.vue @@ -29,9 +29,31 @@ import { type CustomerStatus, } from '@/lib/organizations/customers' import { downloadFile } from '@/lib/common' +import { useRoute, useRouter } from 'vue-router' const { t } = useI18n() -const { state, debouncedTextFilter, statusFilter, sortBy, sortDescending } = useCustomers() +const route = useRoute() +const router = useRouter() +const { + state, + debouncedTextFilter, + statusFilter, + organizationFilter, + sortBy, + sortDescending, + resetFilters, +} = useCustomers() + +// apply the parent company filter requested via query params, then clean the URL +const { organization_id: orgId, organization_name: orgName } = route.query + +if (typeof orgId === 'string' && orgId && typeof orgName === 'string' && orgName) { + resetFilters() + // the filter renders the label carried by the selection: pass the organization + // name, as it may not be among the options the dropdown has loaded + organizationFilter.value = [{ id: orgId, label: orgName }] + router.replace({ query: {} }) +} const isShownCreateCustomerDrawer = ref(false) const isShownImportCustomersModal = ref(false) diff --git a/frontend/src/views/DistributorDetailView.vue b/frontend/src/views/DistributorDetailView.vue index 66288acb1..7055754da 100644 --- a/frontend/src/views/DistributorDetailView.vue +++ b/frontend/src/views/DistributorDetailView.vue @@ -24,6 +24,23 @@ const { state: distributorStats } = useDistributorStats() const { state: distributorSystems } = useDistributorSystems() const { state: applicationsSummary } = useApplicationsSummaryByCompany() +// link to the Resellers page filtered by this distributor as parent company. +// No include_hierarchy: the parent company filter matches exactly, so only the +// resellers this distributor owns are listed. +const resellersRoute = computed(() => { + if (!distributorDetail.value.data || !distributorStats.value.data?.resellers_count) { + return undefined + } + + return { + name: 'resellers', + query: { + organization_id: distributorDetail.value.data.logto_id, + organization_name: distributorDetail.value.data.name, + }, + } +}) + // link to the Systems page filtered by the whole distributor hierarchy const hierarchySystemsRoute = computed(() => { if (!distributorDetail.value.data || !distributorStats.value.data?.systems_hierarchy_count) { @@ -72,6 +89,7 @@ const hierarchySystemsRoute = computed(() => { :counter="distributorStats.data?.resellers_count ?? 0" :icon="faCity" :loading="distributorStats.status === 'pending'" + :to="resellersRoute" /> { + if (!resellerDetail.value.data || !resellerStats.value.data?.customers_count) { + return undefined + } + + return { + name: 'customers', + query: { + organization_id: resellerDetail.value.data.logto_id, + organization_name: resellerDetail.value.data.name, + }, + } +}) + // link to the Systems page filtered by the whole reseller hierarchy const hierarchySystemsRoute = computed(() => { if (!resellerDetail.value.data || !resellerStats.value.data?.systems_hierarchy_count) { @@ -72,6 +89,7 @@ const hierarchySystemsRoute = computed(() => { :counter="resellerStats.data?.customers_count ?? 0" :icon="faBuilding" :loading="resellerStats.status === 'pending'" + :to="customersRoute" /> Date: Mon, 3 Aug 2026 10:28:53 +0200 Subject: [PATCH 06/13] fix(ui): show promotion info in distributor detail --- .../distributors/DistributorInfoCard.vue | 39 ++++++++++++++++++- frontend/src/components/shell/TopBar.vue | 4 +- frontend/src/i18n/en/translation.json | 6 ++- frontend/src/i18n/it/translation.json | 2 + .../src/lib/organizations/distributors.ts | 20 ++++++++++ 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/distributors/DistributorInfoCard.vue b/frontend/src/components/distributors/DistributorInfoCard.vue index 16bd1cb93..5abea158e 100644 --- a/frontend/src/components/distributors/DistributorInfoCard.vue +++ b/frontend/src/components/distributors/DistributorInfoCard.vue @@ -10,6 +10,7 @@ import { NeHeading, NeLink, NeSkeleton, + NeTooltip, type NeDropdownItem, } from '@nethesis/vue-components' import { useDistributorDetail } from '@/queries/organizations/distributorDetail' @@ -32,8 +33,10 @@ import SuspendDistributorModal from './SuspendDistributorModal.vue' import ReactivateDistributorModal from './ReactivateDistributorModal.vue' import { getLanguageLabel } from '@/lib/locale' import { formatPhoneForDisplay } from '@/lib/phone' +import UserAvatar from '../users/UserAvatar.vue' +import { formatDateTimeNoSeconds } from '@/lib/dateTime' -const { t } = useI18n() +const { t, locale } = useI18n() const { state: distributorDetail, asyncStatus } = useDistributorDetail() const isNotesModalShown = ref(false) const isShownCreateOrEditDistributorDrawer = ref(false) @@ -215,6 +218,40 @@ function getKebabMenuItems() { }} + + + + +
diff --git a/frontend/src/components/shell/TopBar.vue b/frontend/src/components/shell/TopBar.vue index 3a5f5eddb..0c2a97591 100644 --- a/frontend/src/components/shell/TopBar.vue +++ b/frontend/src/components/shell/TopBar.vue @@ -15,7 +15,7 @@ import { faCircleQuestion, faCircleUser, faMoon, - faRightFromBracket, + faArrowRightFromBracket, faSun, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' @@ -50,7 +50,7 @@ const accountMenuOptions = computed(() => { { id: 'logout', label: t('shell.sign_out'), - icon: faRightFromBracket, + icon: faArrowRightFromBracket, action: () => loginStore.logout(), disabled: loginStore.isImpersonating, }, diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index e842d022a..844f3b098 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -168,7 +168,7 @@ "reseller_promoted_successfully": "{name} is now a distributor", "promote_reseller": "Promote to distributor", "cannot_promote_reseller": "Cannot promote reseller", - "promote_reseller_confirmation": "Are you sure you want to promote {name} to distributor? It keeps its customers, users and systems, but the distributor that manages it today loses access to them. This action cannot be undone." + "promote_reseller_confirmation": "Reseller {name} will be promoted to distributor. It will keep its own customers, users, and systems, but its current distributor will lose access to them." }, "customers": { "title": "Customers", @@ -244,7 +244,9 @@ "restore_organization_confirmation": "Are you sure you want to restore {name}?", "promote_organization_is_suspended": "This company is suspended. Reactivate it before promoting it.", "promote_organization_not_synced": "This company is not synchronised with the identity provider yet. Try again later.", - "promote_owner_organization_not_found": "The owner company could not be determined, so the promoted company cannot be attached to it." + "promote_owner_organization_not_found": "The owner company could not be determined, so the promoted company cannot be attached to it.", + "promoted_to_distributor": "Promoted to distributor", + "promoted_by_name": "Promoted by {name}" }, "import": { "download_the_template": "Download the template", diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json index 28f397459..3724136ea 100644 --- a/frontend/src/i18n/it/translation.json +++ b/frontend/src/i18n/it/translation.json @@ -232,6 +232,8 @@ "organization_restored": "Azienda ripristinata", "organization_restored_successfully": "{name} ripristinata con successo", "restore_organization_confirmation": "Sei sicuro di voler ripristinare {name}?", + "promoted_to_distributor": "Promosso a distributore", + "promoted_by_name": "Promosso da {name}", "no_company": "Nessuna azienda", "distributors_lc": "distributore | distributori", "resellers_lc": "rivenditore | rivenditori", diff --git a/frontend/src/lib/organizations/distributors.ts b/frontend/src/lib/organizations/distributors.ts index bee37e074..1568f65ef 100644 --- a/frontend/src/lib/organizations/distributors.ts +++ b/frontend/src/lib/organizations/distributors.ts @@ -72,6 +72,26 @@ export const DistributorSchema = v.object({ on_behalf_of: v.optional(v.boolean()), }), ), + // Set only when the distributor was promoted up from a lower level (e.g. a + // reseller promoted to distributor). Absent for distributors created as such. + promoted_from: v.optional( + v.object({ + level: v.string(), + at: v.string(), + detached_from_organization_id: v.optional(v.string()), + by: v.optional( + v.object({ + user_id: v.string(), + username: v.string(), + name: v.string(), + email: v.string(), + organization_id: v.string(), + organization_name: v.string(), + on_behalf_of: v.optional(v.boolean()), + }), + ), + }), + ), }) export type CreateDistributor = v.InferOutput From e50266776078a8a968e1d19db20abb44cd9a3d6c Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Mon, 3 Aug 2026 12:58:46 +0200 Subject: [PATCH 07/13] fix(ui): improve page breadcrumb --- .../src/components/common/PageBreadcrumb.vue | 53 +++++++++++++++++++ frontend/src/i18n/en/translation.json | 1 + frontend/src/views/ApplicationDetailView.vue | 20 ++++--- frontend/src/views/CustomerDetailView.vue | 19 +++---- frontend/src/views/DistributorDetailView.vue | 20 ++++--- frontend/src/views/ResellerDetailView.vue | 20 ++++--- frontend/src/views/SystemDetailView.vue | 28 ++++------ 7 files changed, 100 insertions(+), 61 deletions(-) create mode 100644 frontend/src/components/common/PageBreadcrumb.vue diff --git a/frontend/src/components/common/PageBreadcrumb.vue b/frontend/src/components/common/PageBreadcrumb.vue new file mode 100644 index 000000000..e7e7b8969 --- /dev/null +++ b/frontend/src/components/common/PageBreadcrumb.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 844f3b098..ecaf89610 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -2,6 +2,7 @@ "common": { "access_denied": "Access denied", "this_section_is_accessible_only_by_owner": "This section is accessible only by the owner organization.", + "breadcrumb": "Breadcrumb", "save": "Save", "cancel": "Cancel", "search": "Search", diff --git a/frontend/src/views/ApplicationDetailView.vue b/frontend/src/views/ApplicationDetailView.vue index 4d6b90105..161166cec 100644 --- a/frontend/src/views/ApplicationDetailView.vue +++ b/frontend/src/views/ApplicationDetailView.vue @@ -4,13 +4,13 @@ -->