Files
orion/app/modules/tenancy/static/merchant/js/merchant-team.js
Samir Boulahtit 4748368809
Some checks failed
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
feat(tenancy): expandable per-store rows in merchant team table
Member rows now show a store count with expand/collapse chevron.
Clicking expands sub-rows showing each store with:
- Store name and code
- Per-store role badge
- Per-store status (active/pending independently)
- Per-store actions: resend invitation (pending), remove from store

This fixes the issue where a member active on one store but pending
on another showed misleading combined status and actions.

Member-level actions (view, edit profile) stay on the main row.
Store-level actions (resend, remove) are on each sub-row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:32:47 +02:00

405 lines
13 KiB
JavaScript

// static/merchant/js/merchant-team.js
/**
* Merchant team management page logic
* Manage team members across stores, invitations, and roles
*/
const merchantTeamLog = window.LogConfig.createLogger('merchantTeam');
merchantTeamLog.info('Loading...');
function merchantTeam() {
merchantTeamLog.info('merchantTeam() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'team',
// Team data
members: [],
stores: [],
stats: { total_members: 0, total_active: 0, total_pending: 0 },
// Loading states
loading: false,
error: null,
saving: false,
// Filters
storeFilter: '',
// Expanded member rows
expandedMembers: [],
// Modal states
showInviteModal: false,
showEditModal: false,
showRemoveModal: false,
showViewModal: false,
selectedMember: null,
// Invite form
inviteForm: {
email: '',
first_name: '',
last_name: '',
store_ids: [],
role_name: 'staff',
},
// Role options (preset)
roleOptions: [
{ value: 'manager', label: 'Manager' },
{ value: 'staff', label: 'Staff' },
{ value: 'support', label: 'Support' },
{ value: 'viewer', label: 'Viewer' },
{ value: 'marketing', label: 'Marketing' },
],
async init() {
// Load i18n translations
await I18n.loadModule('tenancy');
merchantTeamLog.info('Team init() called');
// Guard against multiple initialization
if (window._merchantTeamInitialized) {
merchantTeamLog.warn('Already initialized, skipping');
return;
}
window._merchantTeamInitialized = true;
// Call parent init first
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Load dynamic menu
this.loadMenuConfig();
try {
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Init failed:', error);
this.error = 'Failed to initialize team page';
}
merchantTeamLog.info('Team initialization complete');
},
/**
* Load team data (members, stores, stats)
*/
async loadTeamData() {
this.loading = true;
this.error = null;
try {
const response = await apiClient.get('/merchants/account/team');
this.members = response.members || [];
this.stores = response.stores || [];
this.stats = {
total_members: response.total_members || 0,
total_active: response.total_active || 0,
total_pending: response.total_pending || 0,
};
merchantTeamLog.info('Loaded team data:', this.members.length, 'members,', this.stores.length, 'stores');
} catch (error) {
merchantTeamLog.error('Failed to load team data:', error);
this.error = error.message || 'Failed to load team data';
} finally {
this.loading = false;
}
},
/**
* Filter members by store
*/
get filteredMembers() {
if (!this.storeFilter) {
return this.members;
}
const storeId = parseInt(this.storeFilter);
return this.members.filter(member =>
member.stores && member.stores.some(s => s.store_id === storeId)
);
},
/**
* Toggle expand/collapse for a member's store rows
*/
toggleMemberExpand(userId) {
const idx = this.expandedMembers.indexOf(userId);
if (idx > -1) {
this.expandedMembers.splice(idx, 1);
} else {
this.expandedMembers.push(userId);
}
},
/**
* Resend invitation for a specific store membership
*/
async resendStoreInvitation(storeId, userId) {
this.saving = true;
try {
await apiClient.post(
`/merchants/account/team/stores/${storeId}/members/${userId}/resend`
);
Utils.showToast(I18n.t('tenancy.messages.invitation_resent'), 'success');
merchantTeamLog.info('Resent invitation for store:', storeId, 'user:', userId);
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to resend invitation:', error);
Utils.showToast(error.message || 'Failed to resend invitation', 'error');
} finally {
this.saving = false;
}
},
/**
* Open invite modal with reset form
*/
openInviteModal() {
this.inviteForm = {
email: '',
first_name: '',
last_name: '',
store_ids: this.stores.map(s => s.id),
role_name: 'staff',
};
this.showInviteModal = true;
},
/**
* Toggle store in invite form store_ids
*/
toggleStoreSelection(storeId) {
const idx = this.inviteForm.store_ids.indexOf(storeId);
if (idx > -1) {
this.inviteForm.store_ids.splice(idx, 1);
} else {
this.inviteForm.store_ids.push(storeId);
}
},
/**
* Send invitation
*/
async sendInvitation() {
if (!this.inviteForm.email) {
Utils.showToast(I18n.t('tenancy.messages.email_is_required'), 'error');
return;
}
if (this.inviteForm.store_ids.length === 0) {
Utils.showToast(I18n.t('tenancy.messages.select_at_least_one_store'), 'error');
return;
}
this.saving = true;
try {
await apiClient.post('/merchants/account/team/invite', this.inviteForm);
Utils.showToast(I18n.t('tenancy.messages.invitation_sent_successfully'), 'success');
merchantTeamLog.info('Invitation sent to:', this.inviteForm.email);
this.showInviteModal = false;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to send invitation:', error);
Utils.showToast(error.message || 'Failed to send invitation', 'error');
} finally {
this.saving = false;
}
},
/**
* Open view modal for a member (read-only detail)
*/
openViewModal(member) {
this.selectedMember = JSON.parse(JSON.stringify(member));
this.showViewModal = true;
},
/**
* Open edit modal for a member
*/
openEditModal(member) {
this.selectedMember = JSON.parse(JSON.stringify(member));
this.showEditModal = true;
},
/**
* Update member profile (first name, last name, email)
*/
async updateMemberProfile(userId, firstName, lastName, email) {
this.saving = true;
try {
await apiClient.put(
`/merchants/account/team/members/${userId}/profile`,
{ first_name: firstName, last_name: lastName, email: email }
);
Utils.showToast(I18n.t('tenancy.messages.team_member_updated'), 'success');
merchantTeamLog.info('Updated member profile:', userId);
this.showEditModal = false;
this.selectedMember = null;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to update member profile:', error);
Utils.showToast(error.message || 'Failed to update member profile', 'error');
} finally {
this.saving = false;
}
},
/**
* Resend invitation to a pending member
*/
async resendInvitation(member) {
if (!member.stores || member.stores.length === 0) return;
this.saving = true;
try {
// Resend for the first pending store
const pendingStore = member.stores.find(s => s.is_pending) || member.stores[0];
await apiClient.post(
`/merchants/account/team/stores/${pendingStore.store_id}/members/${member.user_id}/resend`
);
Utils.showToast(I18n.t('tenancy.messages.invitation_resent'), 'success');
merchantTeamLog.info('Resent invitation to:', member.email);
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to resend invitation:', error);
Utils.showToast(error.message || 'Failed to resend invitation', 'error');
} finally {
this.saving = false;
}
},
/**
* Update member role for a specific store
*/
async updateMemberRole(storeId, userId, roleName) {
this.saving = true;
try {
await apiClient.put(
`/merchants/account/team/stores/${storeId}/members/${userId}?role_name=${encodeURIComponent(roleName)}`
);
Utils.showToast(I18n.t('tenancy.messages.team_member_updated'), 'success');
merchantTeamLog.info('Updated member role:', userId, 'store:', storeId, 'role:', roleName);
this.showEditModal = false;
this.selectedMember = null;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to update member role:', error);
Utils.showToast(error.message || 'Failed to update member role', 'error');
} finally {
this.saving = false;
}
},
/**
* Open remove confirmation modal
*/
openRemoveModal(member) {
this.selectedMember = JSON.parse(JSON.stringify(member));
this.showRemoveModal = true;
},
/**
* Remove member from a specific store
*/
async removeMember(storeId, userId) {
this.saving = true;
try {
await apiClient.delete(`/merchants/account/team/stores/${storeId}/members/${userId}`);
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
merchantTeamLog.info('Removed member:', userId, 'from store:', storeId);
this.showRemoveModal = false;
this.selectedMember = null;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to remove member:', error);
Utils.showToast(error.message || 'Failed to remove member', 'error');
} finally {
this.saving = false;
}
},
/**
* Remove member from all stores
*/
async removeFromAllStores(member) {
if (!member || !member.stores || member.stores.length === 0) return;
this.saving = true;
try {
for (const store of member.stores) {
await apiClient.delete(
`/merchants/account/team/stores/${store.store_id}/members/${member.user_id}`
);
}
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
merchantTeamLog.info('Removed member from all stores:', member.user_id);
this.showRemoveModal = false;
this.selectedMember = null;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to remove member from all stores:', error);
Utils.showToast(error.message || 'Failed to remove member', 'error');
} finally {
this.saving = false;
}
},
/**
* Get initials for avatar display
*/
getInitials(member) {
const first = member.first_name || member.email?.charAt(0) || '';
const last = member.last_name || '';
return (first.charAt(0) + last.charAt(0)).toUpperCase() || '?';
},
/**
* Get member status based on their store memberships
*/
getMemberStatus(member) {
if (!member.stores || member.stores.length === 0) return 'inactive';
if (member.stores.some(s => s.is_active && !s.is_pending)) return 'active';
if (member.stores.some(s => s.is_pending)) return 'pending';
return 'inactive';
},
/**
* Format date for display
*/
formatDate(dateStr) {
if (!dateStr) return '-';
const locale = window.STORE_CONFIG?.locale || 'en-GB';
return new Date(dateStr).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
};
}