feat(tenancy): add merchant team CRUD with multi-store hub view
The merchant team page was read-only. Now merchant owners can invite, edit roles, and remove team members across all their stores from a single hub view. Architecture: No new models — delegates to existing store_team_service. Members are deduplicated across stores with per-store role badges. New: - 5 API endpoints: GET team (member-centric), GET store roles, POST invite (multi-store), PUT update role, DELETE remove member - merchant-team.js Alpine component with invite/edit/remove modals - Full CRUD template with stats cards, store filter, member table - 7 Pydantic schemas for merchant team request/response - 2 service methods: validate_store_ownership, get_merchant_team_members - 25 new i18n keys across 4 tenancy locales + 1 core common key Tests: 434 tenancy tests passing, arch-check green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
309
app/modules/tenancy/static/merchant/js/merchant-team.js
Normal file
309
app/modules/tenancy/static/merchant/js/merchant-team.js
Normal file
@@ -0,0 +1,309 @@
|
||||
// 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: '',
|
||||
|
||||
// Modal states
|
||||
showInviteModal: false,
|
||||
showEditModal: false,
|
||||
showRemoveModal: 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)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 edit modal for a member
|
||||
*/
|
||||
openEditModal(member) {
|
||||
this.selectedMember = JSON.parse(JSON.stringify(member));
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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_pending)) return 'pending';
|
||||
if (member.stores.some(s => s.is_active)) return 'active';
|
||||
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'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user