From 0455e63a2e0a5de2dbe1fd56d8b3353bb115a178 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Tue, 24 Mar 2026 18:57:45 +0100 Subject: [PATCH] feat(tenancy): add merchant team CRUD with multi-store hub view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/modules/tenancy/locales/de.json | 45 +- app/modules/tenancy/locales/en.json | 45 +- app/modules/tenancy/locales/fr.json | 45 +- app/modules/tenancy/locales/lb.json | 45 +- app/modules/tenancy/routes/api/merchant.py | 122 ++++- app/modules/tenancy/schemas/__init__.py | 6 + app/modules/tenancy/schemas/team.py | 86 ++++ .../services/merchant_store_service.py | 113 +++++ .../static/merchant/js/merchant-team.js | 309 ++++++++++++ .../templates/tenancy/merchant/team.html | 469 +++++++++++++----- static/locales/de.json | 1 + static/locales/en.json | 1 + static/locales/fr.json | 1 + static/locales/lb.json | 1 + 14 files changed, 1131 insertions(+), 158 deletions(-) create mode 100644 app/modules/tenancy/static/merchant/js/merchant-team.js diff --git a/app/modules/tenancy/locales/de.json b/app/modules/tenancy/locales/de.json index cebe55a5..2027871b 100644 --- a/app/modules/tenancy/locales/de.json +++ b/app/modules/tenancy/locales/de.json @@ -12,20 +12,45 @@ "team": "Team" }, "team": { - "title": "Team", - "members": "Mitglieder", + "actions": "Aktionen", + "active": "Aktiv", "add_member": "Mitglied hinzufügen", + "all_stores": "Alle Filialen", + "edit_member": "Mitglied bearbeiten", + "editor": "Bearbeiter", + "email": "E-Mail", + "email_placeholder": "E-Mail-Adresse eingeben", + "error_title": "Fehler beim Laden des Teams", + "first_name": "Vorname", + "invite_first_member": "Laden Sie Ihr erstes Teammitglied ein", "invite_member": "Mitglied einladen", + "invitation_accepted": "Einladung angenommen", + "invitation_sent": "Einladung gesendet", + "last_name": "Nachname", + "loading_team": "Team wird geladen...", + "manage_members_description": "Teammitglieder über alle Filialen verwalten", + "manager": "Manager", + "member": "Mitglied", + "member_stores": "Filialen des Mitglieds", + "members": "Mitglieder", + "no_members_description": "Laden Sie Teammitglieder ein um Ihre Filialen zu verwalten", + "no_members_title": "Noch keine Teammitglieder", + "no_role": "Keine Rolle", + "owner": "Inhaber", + "pending_invitations": "Ausstehende Einladungen", + "permissions": "Berechtigungen", + "remove_confirmation": "Sind Sie sicher, dass Sie entfernen möchten", + "remove_from_all_stores": "Aus allen Filialen entfernen", "remove_member": "Mitglied entfernen", "role": "Rolle", - "owner": "Inhaber", - "manager": "Manager", - "editor": "Bearbeiter", - "viewer": "Betrachter", - "permissions": "Berechtigungen", - "pending_invitations": "Ausstehende Einladungen", - "invitation_sent": "Einladung gesendet", - "invitation_accepted": "Einladung angenommen" + "select_stores": "Filialen auswählen", + "send_invitation": "Einladung senden", + "status": "Status", + "store_roles": "Filialrollen", + "stores_and_roles": "Filialen & Rollen", + "title": "Team", + "total_members": "Mitglieder gesamt", + "viewer": "Betrachter" }, "messages": { "business_info_saved": "Business info saved", diff --git a/app/modules/tenancy/locales/en.json b/app/modules/tenancy/locales/en.json index 548dae39..9044de75 100644 --- a/app/modules/tenancy/locales/en.json +++ b/app/modules/tenancy/locales/en.json @@ -12,20 +12,45 @@ "team": "Team" }, "team": { - "title": "Team", - "members": "Members", + "actions": "Actions", + "active": "Active", "add_member": "Add Member", + "all_stores": "All Stores", + "edit_member": "Edit Member", + "editor": "Editor", + "email": "Email", + "email_placeholder": "Enter email address", + "error_title": "Error loading team", + "first_name": "First Name", + "invite_first_member": "Invite your first team member", "invite_member": "Invite Member", + "invitation_accepted": "Invitation Accepted", + "invitation_sent": "Invitation Sent", + "last_name": "Last Name", + "loading_team": "Loading team...", + "manage_members_description": "Manage team members across all your stores", + "manager": "Manager", + "member": "Member", + "member_stores": "Member's stores", + "members": "Members", + "no_members_description": "Invite team members to help manage your stores", + "no_members_title": "No team members yet", + "no_role": "No role", + "owner": "Owner", + "pending_invitations": "Pending Invitations", + "permissions": "Permissions", + "remove_confirmation": "Are you sure you want to remove", + "remove_from_all_stores": "Remove from all stores", "remove_member": "Remove Member", "role": "Role", - "owner": "Owner", - "manager": "Manager", - "editor": "Editor", - "viewer": "Viewer", - "permissions": "Permissions", - "pending_invitations": "Pending Invitations", - "invitation_sent": "Invitation Sent", - "invitation_accepted": "Invitation Accepted" + "select_stores": "Select Stores", + "send_invitation": "Send Invitation", + "status": "Status", + "store_roles": "Store Roles", + "stores_and_roles": "Stores & Roles", + "title": "Team", + "total_members": "Total Members", + "viewer": "Viewer" }, "messages": { "business_info_saved": "Business info saved", diff --git a/app/modules/tenancy/locales/fr.json b/app/modules/tenancy/locales/fr.json index 46905d1f..929bee41 100644 --- a/app/modules/tenancy/locales/fr.json +++ b/app/modules/tenancy/locales/fr.json @@ -12,20 +12,45 @@ "team": "Équipe" }, "team": { - "title": "Équipe", - "members": "Membres", + "actions": "Actions", + "active": "Actifs", "add_member": "Ajouter un membre", + "all_stores": "Tous les magasins", + "edit_member": "Modifier le membre", + "editor": "Éditeur", + "email": "E-mail", + "email_placeholder": "Saisir l'adresse e-mail", + "error_title": "Erreur lors du chargement", + "first_name": "Prénom", + "invite_first_member": "Invitez votre premier membre", "invite_member": "Inviter un membre", + "invitation_accepted": "Invitation acceptée", + "invitation_sent": "Invitation envoyée", + "last_name": "Nom de famille", + "loading_team": "Chargement de l'équipe...", + "manage_members_description": "Gérer les membres de l'équipe sur tous vos magasins", + "manager": "Gestionnaire", + "member": "Membre", + "member_stores": "Magasins du membre", + "members": "Membres", + "no_members_description": "Invitez des membres pour gérer vos magasins", + "no_members_title": "Aucun membre encore", + "no_role": "Aucun rôle", + "owner": "Propriétaire", + "pending_invitations": "Invitations en attente", + "permissions": "Permissions", + "remove_confirmation": "Êtes-vous sûr de vouloir supprimer", + "remove_from_all_stores": "Supprimer de tous les magasins", "remove_member": "Retirer un membre", "role": "Rôle", - "owner": "Propriétaire", - "manager": "Gestionnaire", - "editor": "Éditeur", - "viewer": "Lecteur", - "permissions": "Permissions", - "pending_invitations": "Invitations en attente", - "invitation_sent": "Invitation envoyée", - "invitation_accepted": "Invitation acceptée" + "select_stores": "Sélectionner les magasins", + "send_invitation": "Envoyer l'invitation", + "status": "Statut", + "store_roles": "Rôles par magasin", + "stores_and_roles": "Magasins et rôles", + "title": "Équipe", + "total_members": "Membres totaux", + "viewer": "Lecteur" }, "messages": { "business_info_saved": "Business info saved", diff --git a/app/modules/tenancy/locales/lb.json b/app/modules/tenancy/locales/lb.json index 693f4e44..91874997 100644 --- a/app/modules/tenancy/locales/lb.json +++ b/app/modules/tenancy/locales/lb.json @@ -12,20 +12,45 @@ "team": "Team" }, "team": { - "title": "Team", - "members": "Memberen", + "actions": "Aktiounen", + "active": "Aktiv", "add_member": "Member derbäisetzen", + "all_stores": "All Geschäfter", + "edit_member": "Member änneren", + "editor": "Editeur", + "email": "E-Mail", + "email_placeholder": "E-Mail-Adress aginn", + "error_title": "Feeler beim Lueden vum Team", + "first_name": "Virnumm", + "invite_first_member": "Invitéiert Äert éischt Teammember", "invite_member": "Member invitéieren", + "invitation_accepted": "Invitatioun ugeholl", + "invitation_sent": "Invitatioun geschéckt", + "last_name": "Nonumm", + "loading_team": "Team gëtt gelueden...", + "manage_members_description": "Teammemberen iwwer all Geschäfter verwalten", + "manager": "Manager", + "member": "Member", + "member_stores": "Geschäfter vum Member", + "members": "Memberen", + "no_members_description": "Invitéiert Teammemberen fir Är Geschäfter ze verwalten", + "no_members_title": "Nach keng Teammemberen", + "no_role": "Keng Roll", + "owner": "Proprietär", + "pending_invitations": "Aussteesend Invitatiounen", + "permissions": "Rechter", + "remove_confirmation": "Sidd Dir sécher, datt Dir ewechhuele wëllt", + "remove_from_all_stores": "Vun all Geschäfter ewechhuelen", "remove_member": "Member ewechhuelen", "role": "Roll", - "owner": "Proprietär", - "manager": "Manager", - "editor": "Editeur", - "viewer": "Betruechter", - "permissions": "Rechter", - "pending_invitations": "Aussteesend Invitatiounen", - "invitation_sent": "Invitatioun geschéckt", - "invitation_accepted": "Invitatioun ugeholl" + "select_stores": "Geschäfter wielen", + "send_invitation": "Invitatioun schécken", + "status": "Status", + "store_roles": "Geschäftsrollen", + "stores_and_roles": "Geschäfter & Rollen", + "title": "Team", + "total_members": "Memberen total", + "viewer": "Betruechter" }, "messages": { "business_info_saved": "Business info saved", diff --git a/app/modules/tenancy/routes/api/merchant.py b/app/modules/tenancy/routes/api/merchant.py index ae7a672e..4377a710 100644 --- a/app/modules/tenancy/routes/api/merchant.py +++ b/app/modules/tenancy/routes/api/merchant.py @@ -168,11 +168,127 @@ async def merchant_team_overview( db: Session = Depends(get_db), ): """ - Get team members across all stores owned by the merchant. + Get team members across all merchant stores (member-centric view). - Returns a list of stores with their team members grouped by store. + Returns deduplicated members with per-store role info. """ - return merchant_store_service.get_merchant_team_overview(db, merchant.id) + return merchant_store_service.get_merchant_team_members(db, merchant.id) + + +@_account_router.get("/team/stores/{store_id}/roles") +async def merchant_team_store_roles( + store_id: int, + current_user: UserContext = Depends(get_current_merchant_api), + merchant=Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Get available roles for a specific store.""" + from app.modules.tenancy.services.store_team_service import store_team_service + + merchant_store_service.validate_store_ownership(db, merchant.id, store_id) + roles = store_team_service.get_store_roles(db, store_id) + return {"roles": roles, "total": len(roles)} + + +@_account_router.post("/team/invite") +async def merchant_team_invite( + data: "MerchantTeamInvite", + current_user: UserContext = Depends(get_current_merchant_api), + merchant=Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Invite a member to one or more merchant stores.""" + from app.modules.tenancy.schemas.team import ( + MerchantTeamInviteResponse, + MerchantTeamInviteResult, + ) + from app.modules.tenancy.services.store_team_service import store_team_service + + # Get the User ORM object (service needs it as inviter) + inviter = merchant_store_service.get_user(db, current_user.id) + + results = [] + for store_id in data.store_ids: + try: + store = merchant_store_service.validate_store_ownership( + db, merchant.id, store_id + ) + store_team_service.invite_team_member( + db, + store=store, + inviter=inviter, + email=data.email, + role_name=data.role_name, + ) + results.append(MerchantTeamInviteResult( + store_id=store.id, + store_name=store.name, + success=True, + )) + except Exception as e: + results.append(MerchantTeamInviteResult( + store_id=store_id, + store_name=getattr(e, "store_name", str(store_id)), + success=False, + error=str(e), + )) + + success_count = sum(1 for r in results if r.success) + if success_count == len(results): + message = f"Invitation sent to {data.email} for {success_count} store(s)" + elif success_count > 0: + message = f"Invitation partially sent ({success_count}/{len(results)} stores)" + else: + message = "Invitation failed for all stores" + + return MerchantTeamInviteResponse( + message=message, + email=data.email, + results=results, + ) + + +@_account_router.put("/team/stores/{store_id}/members/{user_id}") +async def merchant_team_update_role( + store_id: int, + user_id: int, + role_name: str = Query(..., description="New role name"), + current_user: UserContext = Depends(get_current_merchant_api), + merchant=Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Update a member's role in a specific store.""" + from app.modules.tenancy.services.store_team_service import store_team_service + + store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id) + store_team_service.update_member_role( + db, + store=store, + user_id=user_id, + new_role_name=role_name, + actor_user_id=current_user.id, + ) + return {"message": "Role updated successfully"} + + +@_account_router.delete("/team/stores/{store_id}/members/{user_id}", status_code=204) +async def merchant_team_remove_member( + store_id: int, + user_id: int, + current_user: UserContext = Depends(get_current_merchant_api), + merchant=Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Remove a member from a specific store.""" + from app.modules.tenancy.services.store_team_service import store_team_service + + store = merchant_store_service.validate_store_ownership(db, merchant.id, store_id) + store_team_service.remove_team_member( + db, + store=store, + user_id=user_id, + actor_user_id=current_user.id, + ) @_account_router.get("/profile", response_model=MerchantPortalProfileResponse) diff --git a/app/modules/tenancy/schemas/__init__.py b/app/modules/tenancy/schemas/__init__.py index 873f25a6..77282922 100644 --- a/app/modules/tenancy/schemas/__init__.py +++ b/app/modules/tenancy/schemas/__init__.py @@ -118,6 +118,12 @@ from app.modules.tenancy.schemas.team import ( InvitationAccept, InvitationAcceptResponse, InvitationResponse, + MerchantTeamInvite, + MerchantTeamInviteResponse, + MerchantTeamMemberResponse, + MerchantTeamMemberStoreInfo, + MerchantTeamOverviewResponse, + MerchantTeamStoreInfo, PermissionCheckRequest, PermissionCheckResponse, RoleBase, diff --git a/app/modules/tenancy/schemas/team.py b/app/modules/tenancy/schemas/team.py index 4d28e002..145368c9 100644 --- a/app/modules/tenancy/schemas/team.py +++ b/app/modules/tenancy/schemas/team.py @@ -315,3 +315,89 @@ class TeamErrorResponse(BaseModel): error_code: str message: str details: dict | None = None + + +# ============================================================================ +# Merchant Team Schemas (Hub View) +# ============================================================================ + + +class MerchantTeamMemberStoreInfo(BaseModel): + """A member's role/status in one specific store.""" + + store_id: int + store_name: str + store_code: str + role_name: str | None = None + role_id: int | None = None + is_active: bool = True + is_pending: bool = False + + +class MerchantTeamMemberResponse(BaseModel): + """A team member aggregated across all merchant stores.""" + + user_id: int + email: EmailStr + first_name: str | None = None + last_name: str | None = None + full_name: str + stores: list[MerchantTeamMemberStoreInfo] = Field(default_factory=list) + is_owner: bool = False + + +class MerchantTeamStoreInfo(BaseModel): + """Compact store info for the merchant team overview.""" + + id: int + name: str + code: str + + +class MerchantTeamOverviewResponse(BaseModel): + """Merchant team overview with member-centric view.""" + + merchant_name: str + stores: list[MerchantTeamStoreInfo] + members: list[MerchantTeamMemberResponse] + total_members: int + total_active: int + total_pending: int + + +class MerchantTeamInvite(BaseModel): + """Schema for inviting a member to merchant stores.""" + + email: EmailStr + first_name: str | None = Field(None, max_length=100) + last_name: str | None = Field(None, max_length=100) + store_ids: list[int] = Field(..., min_length=1, description="Store IDs to invite to") + role_name: str = Field("staff", description="Role name for all selected stores") + + @field_validator("role_name") + @classmethod + def validate_role_name(cls, v): + """Validate role name is in allowed presets.""" + allowed_roles = ["manager", "staff", "support", "viewer", "marketing"] + if v.lower() not in allowed_roles: + raise ValueError( + f"Role name must be one of: {', '.join(allowed_roles)}" + ) + return v.lower() + + +class MerchantTeamInviteResult(BaseModel): + """Per-store invite result.""" + + store_id: int + store_name: str + success: bool + error: str | None = None + + +class MerchantTeamInviteResponse(BaseModel): + """Response for merchant team invite (multi-store).""" + + message: str + email: EmailStr + results: list[MerchantTeamInviteResult] diff --git a/app/modules/tenancy/services/merchant_store_service.py b/app/modules/tenancy/services/merchant_store_service.py index 1a5b3926..42f08d26 100644 --- a/app/modules/tenancy/services/merchant_store_service.py +++ b/app/modules/tenancy/services/merchant_store_service.py @@ -431,6 +431,119 @@ class MerchantStoreService: } + def get_user(self, db: Session, user_id: int): + """Get a User ORM object by ID.""" + from app.modules.tenancy.models import User + + return db.query(User).filter(User.id == user_id).first() + + def validate_store_ownership( + self, db: Session, merchant_id: int, store_id: int + ) -> Store: + """ + Validate that a store belongs to the merchant. + + Returns the Store object if valid, raises exception otherwise. + """ + store = ( + db.query(Store) + .filter(Store.id == store_id, Store.merchant_id == merchant_id) + .first() + ) + if not store: + from app.modules.tenancy.exceptions import StoreNotFoundException + + raise StoreNotFoundException(store_id, identifier_type="id") + return store + + def get_merchant_team_members(self, db: Session, merchant_id: int) -> dict: + """ + Get team members across all merchant stores in a member-centric view. + + Deduplicates users across stores and aggregates per-store role info. + """ + from app.modules.tenancy.models.store import StoreUser + + merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first() + if not merchant: + raise MerchantNotFoundException(merchant_id) + + stores = ( + db.query(Store) + .filter(Store.merchant_id == merchant_id) + .order_by(Store.name) + .all() + ) + + # Build member-centric view: keyed by user_id + members_map: dict[int, dict] = {} + store_list = [] + + for store in stores: + store_list.append({ + "id": store.id, + "name": store.name, + "code": store.store_code, + }) + + store_users = ( + db.query(StoreUser) + .filter(StoreUser.store_id == store.id) + .all() + ) + + for su in store_users: + user = su.user + if not user: + continue + + uid = user.id + is_pending = su.invitation_accepted_at is None and su.invitation_token is not None + + if uid not in members_map: + members_map[uid] = { + "user_id": uid, + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or user.email, + "stores": [], + "is_owner": uid == merchant.owner_user_id, + } + + members_map[uid]["stores"].append({ + "store_id": store.id, + "store_name": store.name, + "store_code": store.store_code, + "role_name": su.role.name if su.role else None, + "role_id": su.role_id, + "is_active": su.is_active, + "is_pending": is_pending, + }) + + members = list(members_map.values()) + # Owner first, then alphabetical + members.sort(key=lambda m: (not m["is_owner"], m["full_name"].lower())) + + total_active = sum( + 1 for m in members + if any(s["is_active"] and not s["is_pending"] for s in m["stores"]) + ) + total_pending = sum( + 1 for m in members + if any(s["is_pending"] for s in m["stores"]) + ) + + return { + "merchant_name": merchant.name, + "stores": store_list, + "members": members, + "total_members": len(members), + "total_active": total_active, + "total_pending": total_pending, + } + + # Singleton instance merchant_store_service = MerchantStoreService() diff --git a/app/modules/tenancy/static/merchant/js/merchant-team.js b/app/modules/tenancy/static/merchant/js/merchant-team.js new file mode 100644 index 00000000..7c9a8a38 --- /dev/null +++ b/app/modules/tenancy/static/merchant/js/merchant-team.js @@ -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' + }); + } + }; +} diff --git a/app/modules/tenancy/templates/tenancy/merchant/team.html b/app/modules/tenancy/templates/tenancy/merchant/team.html index 8410cb24..a59879ad 100644 --- a/app/modules/tenancy/templates/tenancy/merchant/team.html +++ b/app/modules/tenancy/templates/tenancy/merchant/team.html @@ -1,140 +1,379 @@ {# app/modules/tenancy/templates/tenancy/merchant/team.html #} {% extends "merchant/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} +{% from 'shared/macros/modals.html' import modal, confirm_modal %} -{% block title %}{{ _("tenancy.team.title") }}{% endblock %} +{% block title %}{{ _('tenancy.team.title') }}{% endblock %} +{% block alpine_data %}merchantTeam(){% endblock %} {% block content %} -
- -
-
+ +{% call page_header_flex(title=_('tenancy.team.title'), subtitle=_('tenancy.team.manage_members_description')) %} + +{% endcall %} + + +{{ loading_state(_('tenancy.team.loading_team'), 'loading') }} + + +{{ error_state(_('tenancy.team.error_title'), 'error', 'error && !loading') }} + + +
+ + +
+ +
+
+ +
-

{{ _("tenancy.team.title") }}

-

- {{ _("tenancy.team.members") }} - -

+

{{ _('tenancy.team.total_members') }}

+

+
+
+ + +
+
+ +
+
+

{{ _('tenancy.team.active') }}

+

+
+
+ + +
+
+ +
+
+

{{ _('tenancy.team.pending_invitations') }}

+

- -
-

+ +
+
- -
- - - - - {{ _("common.loading") }} + +
+ {% call table_wrapper() %} + {{ table_header([_('tenancy.team.member'), _('tenancy.team.stores_and_roles'), _('tenancy.team.status'), _('tenancy.team.actions')]) }} + + + + {% endcall %}
- -
- - - - + +
+ +
-
+{% endcall %} + + +{% call modal('removeModal', _('tenancy.team.remove_member'), 'showRemoveModal', size='sm', show_footer=false) %} +
+ +
+ +

+ {{ _('tenancy.team.remove_confirmation') }} + +

+
+ + +
+

{{ _('tenancy.team.member_stores') }}:

+ +
+ + +
+ + +
+
+{% endcall %} + {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/static/locales/de.json b/static/locales/de.json index c0bee7b9..98a057a4 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -22,6 +22,7 @@ "saving": "Speichern...", "processing": "Verarbeiten...", "searching": "Suchen...", + "sending": "Wird gesendet", "refresh": "Aktualisieren", "retry": "Erneut versuchen", "view": "Ansehen", diff --git a/static/locales/en.json b/static/locales/en.json index 422725da..35d76373 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -22,6 +22,7 @@ "saving": "Saving...", "processing": "Processing...", "searching": "Searching...", + "sending": "Sending", "refresh": "Refresh", "retry": "Retry", "view": "View", diff --git a/static/locales/fr.json b/static/locales/fr.json index 06f01985..ed4f0041 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -22,6 +22,7 @@ "saving": "Enregistrement...", "processing": "Traitement...", "searching": "Recherche...", + "sending": "Envoi en cours", "refresh": "Actualiser", "retry": "Réessayer", "view": "Voir", diff --git a/static/locales/lb.json b/static/locales/lb.json index 0fce3a4c..6a1051a7 100644 --- a/static/locales/lb.json +++ b/static/locales/lb.json @@ -22,6 +22,7 @@ "saving": "Späicheren...", "processing": "Veraarbechten...", "searching": "Sichen...", + "sending": "Gëtt geschéckt", "refresh": "Aktualiséieren", "retry": "Nach eng Kéier probéieren", "view": "Kucken",