Some checks failed
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>
405 lines
13 KiB
JavaScript
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'
|
|
});
|
|
}
|
|
};
|
|
}
|