Partner Country Executives UI
Frontend implementation of the Country Assignments section and the bulk-assign action embedded in the existing Partners list.
Routes & Entry Points
The module ships two cooperating UI surfaces:
// frontend/client/src/routes/index.js
{
prefix: '/partners',
layout: 'dashboard',
children: ['', ':id/clients', /* ... */, 'crm', 'country-assignments'],
}| Route | Component | Purpose |
|---|---|---|
/partners/country-assignments | views/partners/country-assignments/index.vue | Standalone page with PAE and SDR tabs to manage executives |
/partners | views/partners/index.vue | Existing partners list, now hosting the bulk-assign offcanvas |
The Country Assignments page is registered as a child of the existing Partners route so it appears alongside /partners, /partners/payments, /partners/crm, etc.
File Tree
frontend/client/src/
├── routes/index.js ← /partners/country-assignments registered
├── services/
│ ├── index.js ← registers partnerCountryExecutives + partner.bulk*
│ ├── partner.service.js ← + bulkAssignAdministrator / bulkAssignSalesman
│ └── partnerCountryExecutives.service.js ← new: list/get/create/update/remove/listActive/...
└── views/
└── partners/
├── index.vue ← hosts BulkAssignExecutiveOffcanvas
├── components/
│ ├── Table.vue ← + bulk action buttons (PAE / Salesman)
│ └── BulkAssignExecutiveOffcanvas.vue ← new: shared by both bulk actions
└── country-assignments/
├── index.vue ← page root + tabs (PAE / SDR)
├── constants/index.js ← ROLE_TYPE + STATUS_OPTIONS
└── components/
├── AssignmentFormOffcanvas.vue ← create/edit panel (role-agnostic)
├── PaeAssignmentsTable.vue ← PAE-specific datatable
├── SdrAssignmentsTable.vue ← Salesman/SDR-specific datatable
└── CountryChips.vue ← country pill list + fallback chipPage Layout: Country Assignments
views/partners/country-assignments/index.vue renders a SectionTitle and a Tabs component with two tabs:
| Tab key | Component | Backing role type |
|---|---|---|
pae | PaeAssignmentsTable.vue | 'pae' |
sdr | SdrAssignmentsTable.vue | 'salesman' |
Both tabs share the same shape and almost identical logic — they differ only in the role type passed to the API and the i18n namespace they consume (countryAssignments.pae.* vs countryAssignments.sdr.*).
Tab content
Each table renders, top to bottom:
- No-fallback warning banner — visible while
getGlobalFallback(role)returnsnull. Tells the operator that there is currently no global fallback configured for the role type. - Description banner — static info block explaining what this tab does.
- Activate button — opens
AssignmentFormOffcanvasin create mode for the role. - DataTable — paginated list of executives for that role type with filters (
administrator_id/ name / email, country, status) and per-row actions (View / Delete).
Role-agnostic table component
Both PaeAssignmentsTable.vue and SdrAssignmentsTable.vue render almost identical templates. They are kept as separate files (rather than parameterized) so the i18n keys, role-specific copy and per-tab tweaks (e.g. Status color) remain easy to scan and customize.
Component Responsibilities
country-assignments/index.vue
- Mounts the
Tabscomponent with two static tabs (pae,sdr). - No data fetching — each tab is self-sufficient and lazy-renders its content the first time it is shown.
country-assignments/components/PaeAssignmentsTable.vue / SdrAssignmentsTable.vue
Each table:
- Loads the locales catalog (
api.catalog.getLocales) for the country filter dropdown and forCountryChips. - Loads the global fallback (
api.partnerCountryExecutives.getGlobalFallback(role)) on mount and after every save / delete. The presence of a fallback drives the warning banner. - Loads the table data through the shared
DataTableloadDataprop, callingapi.partnerCountryExecutives.list({ ...filters, role_type }). - Shows a per-row
Viewbutton that opensAssignmentFormOffcanvasin edit mode pre-populated with the row, and aDeleteaction under the row's dropdown. - Both tables include a permission gate (
canManage) controlled byENFORCE_PERMISSIONS. The flag is set tofalsehere for the same reason as in the backend: themanage-partner-country-executivespermission is not yet seeded. Flipping it totruere-enables the gate without further changes.
country-assignments/components/AssignmentFormOffcanvas.vue
Role-agnostic panel for both create and edit, opened with show() (create) or show(item) (edit).
| Behavior | Detail |
|---|---|
| Header copy | Picked from `countryAssignments.offcanvas.{pae |
| Catalogs | On show() it fires api.catalog.getLocales() always, and api.partnerCountryExecutives.getAvailableAdministrators(role) only in create mode. |
| Edit-mode admin dropdown | The available-administrators endpoint excludes the current admin (since they already have an assignment). The component prepends the current admin manually so it stays visible — the field is disabled in edit mode. |
| Fields | administrator_id (select), country_ids (multi-select / tags), is_global_fallback (switch), and active (switch, only in edit mode). |
| Fallback warning | When is_global_fallback is on, an alert reminds the operator that any partner not matched to a specific country will be routed to this executive. |
| Submit | Calls create(payload) or update(id, payload) and emits @saved so the parent table can reload() and re-fetch the global fallback. |
The form transforms the UI's country_ids array into the API's locale_ids field at the boundary so the rest of the page only needs to know about "countries".
country-assignments/components/CountryChips.vue
Stateless presentation component. Renders one pill per country (with the country flag svg from ${env.S3_PATH}/uploads/locales/<code>.svg) plus an optional "Global fallback" chip. Falls back to a "No countries" muted label when both lists are empty.
Bulk Assignment Integration (Partners list)
The Partners table (views/partners/components/Table.vue) already supports row selection. Two new bulk action buttons are rendered above the table when auth.can('update-partner-administrator') is true:
| Button | Calls | Endpoint |
|---|---|---|
| Asignar PAE | openBulkAssign('pae') | POST /partners/bulk-assign-administrator |
| Asignar Salesman | openBulkAssign('salesman') | POST /partners/bulk-assign-salesman |
The button collects table.itemsSelected (Vue ref or array, depending on the DataTable implementation), guards against an empty selection, and emits openBulkAssign with { role, partners } to the parent (views/partners/index.vue), which forwards the payload to the offcanvas.
components/BulkAssignExecutiveOffcanvas.vue
A single offcanvas covers both PAE and Salesman bulk operations:
- On
show({ role, partners })it fetchesapi.partnerCountryExecutives.listActive(role). The dropdown options are built from the response, with the global fallback (if any) appearing first thanks to the backend ordering (computedis_global_fallback DESC, u.name ASC). - The selected partners are rendered as a compact, scrollable card so the operator can verify the scope of the action before confirming.
- On submit it calls
api.partner.bulkAssignAdministrator(payload)(PAE) orapi.partner.bulkAssignSalesman(payload)(Salesman). The success toast surfaces bothupdated.lengthandskipped.lengthfrom the response so the operator knows how many partners were no-ops.
Source of truth
The dropdown is intentionally fed by the active executives (administrators with status_leads = 1 and the matching role), not by all administrators with the right role. This guarantees that the operator can only assign partners to administrators that have been explicitly enabled in the Country Assignments section (or via the administrator edit toggle below).
Activation toggle on the Administrator edit modal
views/administratorsV2/components/administrators/EditAdministrator.modal.vue exposes the existing status_leads field (leads assignment), which now doubles as the executive registry flag for partner-referral roles. Because it is the same column, toggling it here (or via the administrators table toggle, permission administrators-edit-leads) has the same effect as activating/pausing the executive from the Country Assignments section. There is no longer a separate allow_partner_referral_assignment field.
Service Layer
services/partnerCountryExecutives.service.js
| Method | HTTP | Endpoint |
|---|---|---|
list(filters) | GET | /partner-country-executives |
getById(id) | GET | /partner-country-executives/{id} |
getAvailableAdministrators(roleType) | GET | /partner-country-executives/available-administrators |
getGlobalFallback(roleType) | GET | /partner-country-executives/global-fallback |
listActive(roleType) | GET | /partner-country-executives/active |
create(payload) | POST | /partner-country-executives |
update(id, payload) | PUT | /partner-country-executives/{id} |
remove(id) | DELETE | /partner-country-executives/{id} |
list calls resolveTableParams(filters) so DataTable filters are translated into the query parameters expected by the backend (start, length, sortBy, sortType, plus the module-specific filters).
services/partner.service.js (additions)
| Method | HTTP | Endpoint |
|---|---|---|
bulkAssignAdministrator(payload) | POST | /partners/bulk-assign-administrator |
bulkAssignSalesman(payload) | POST | /partners/bulk-assign-salesman |
Both methods are thin wrappers over axios.post; the global response interceptor unwraps response.data so the caller receives { updated, skipped } directly.
Permission Gates
| Gate | Component | Check |
|---|---|---|
Show Activate PAE button | PaeAssignmentsTable.vue | canManage = ENFORCE_PERMISSIONS ? auth.can('manage-partner-country-executives') : true |
Show Activate SDR button | SdrAssignmentsTable.vue | same as above |
Show Delete row action | both tables | same as above |
Show Asignar PAE / Asignar Salesman bulk buttons | views/partners/components/Table.vue | auth.can('update-partner-administrator') |
| Backend list / get | GET /partner-country-executives* | view-partner-country-executives (currently bypassed) |
| Backend write | POST/PUT/DELETE /partner-country-executives* | manage-partner-country-executives (currently bypassed) |
| Backend bulk-assign | POST /partners/bulk-assign-* | update-partner-administrator (enforced) |
Temporary permission bypass
Both the routes and the UI ship with a const ENFORCE_PERMISSIONS = false flag. Once view-partner-country-executives and manage-partner-country-executives are seeded in catalog_admin_permissions, flip both flags to true. No other code change is needed: the permission names are already wired to permissionMiddleware.canByName (backend) and auth.can() (frontend).
i18n keys (countryAssignments.*)
The Country Assignments page uses a single i18n namespace shared across both tabs, with role-specific sub-namespaces for any text that diverges between PAE and SDR:
countryAssignments.title/subtitle/tabs.{pae|sdr}— page chrome.countryAssignments.role.{pae|salesman}— role label rendered inside row chips.countryAssignments.{pae|sdr}.activateBtn/noFallbackTitle/noFallbackDescription/descriptionTitle/description/deleteFallbackBlocked/deleteSuccess/activateSuccess— role-specific copy.countryAssignments.offcanvas.{pae|sdr}.{create|edit}{Title|Subtitle}— header copy inAssignmentFormOffcanvas.countryAssignments.offcanvas.{adminLabel|adminPlaceholder|countriesLabel|countriesPlaceholder|fallbackLabel|fallbackHint|fallbackWarning|activeLabel|activeHint|loadingCatalogs|updateSuccess}— shared form labels.countryAssignments.columns.{adminId|executive|role|countries|status|actions}— DataTable headers.countryAssignments.filters.{searchById|searchByName|searchByEmail|country|status}— DataTable filters.countryAssignments.{viewInfoBtn|deleteConfirm|fallbackTooltip|fallbackChip|noCountries|status.{active|inactive}}— shared chrome.
The bulk-assign offcanvas uses the partners.bulkAssign.* namespace (same role split via partners.bulkAssign.{pae|salesman}.*).
