feat(tenancy): add member detail modal + fix invite name saving
Some checks failed
Some checks failed
Merchant team page: - Consistent member display (full_name + email on every row) - New view button (eye icon) on all members including owner - View modal shows account info (username, role, email verified, last login, account created) and store memberships with roles - API enriched with user metadata (username, role, is_email_verified, last_login, created_at) Invite fix (both merchant and store routes): - first_name and last_name from invite form were never passed to the service that creates the User account. Now passed through correctly. i18n: 6 new keys across 4 locales (view_member, account_information, username, email_verified, last_login, account_created). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,12 @@
|
||||
"stores_and_roles": "Filialen & Rollen",
|
||||
"title": "Team",
|
||||
"total_members": "Mitglieder gesamt",
|
||||
"view_member": "Mitglied anzeigen",
|
||||
"account_information": "Kontoinformationen",
|
||||
"username": "Benutzername",
|
||||
"email_verified": "E-Mail verifiziert",
|
||||
"last_login": "Letzte Anmeldung",
|
||||
"account_created": "Konto erstellt",
|
||||
"viewer": "Betrachter"
|
||||
},
|
||||
"messages": {
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
"stores_and_roles": "Stores & Roles",
|
||||
"title": "Team",
|
||||
"total_members": "Total Members",
|
||||
"view_member": "View Member",
|
||||
"account_information": "Account Information",
|
||||
"username": "Username",
|
||||
"email_verified": "Email Verified",
|
||||
"last_login": "Last Login",
|
||||
"account_created": "Account Created",
|
||||
"viewer": "Viewer"
|
||||
},
|
||||
"messages": {
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
"stores_and_roles": "Magasins et rôles",
|
||||
"title": "Équipe",
|
||||
"total_members": "Membres totaux",
|
||||
"view_member": "Voir le membre",
|
||||
"account_information": "Informations du compte",
|
||||
"username": "Nom d'utilisateur",
|
||||
"email_verified": "Email vérifié",
|
||||
"last_login": "Dernière connexion",
|
||||
"account_created": "Compte créé",
|
||||
"viewer": "Lecteur"
|
||||
},
|
||||
"messages": {
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
"stores_and_roles": "Geschäfter & Rollen",
|
||||
"title": "Team",
|
||||
"total_members": "Memberen total",
|
||||
"view_member": "Member kucken",
|
||||
"account_information": "Konto Informatiounen",
|
||||
"username": "Benotzernumm",
|
||||
"email_verified": "Email verifizéiert",
|
||||
"last_login": "Lescht Umeldung",
|
||||
"account_created": "Konto erstallt",
|
||||
"viewer": "Betruechter"
|
||||
},
|
||||
"messages": {
|
||||
|
||||
@@ -220,6 +220,8 @@ async def merchant_team_invite(
|
||||
inviter=inviter,
|
||||
email=data.email,
|
||||
role_name=data.role_name,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
)
|
||||
results.append(MerchantTeamInviteResult(
|
||||
store_id=store.id,
|
||||
|
||||
@@ -140,6 +140,8 @@ def invite_team_member(
|
||||
inviter=current_user,
|
||||
email=invitation.email,
|
||||
role_id=invitation.role_id,
|
||||
first_name=invitation.first_name,
|
||||
last_name=invitation.last_name,
|
||||
)
|
||||
elif invitation.role_name:
|
||||
# Use role name with optional custom permissions
|
||||
@@ -149,6 +151,8 @@ def invite_team_member(
|
||||
inviter=current_user,
|
||||
email=invitation.email,
|
||||
role_name=invitation.role_name,
|
||||
first_name=invitation.first_name,
|
||||
last_name=invitation.last_name,
|
||||
custom_permissions=invitation.custom_permissions,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -504,9 +504,15 @@ class MerchantStoreService:
|
||||
members_map[uid] = {
|
||||
"user_id": uid,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"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,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"is_email_verified": user.is_email_verified,
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"stores": [],
|
||||
"is_owner": uid == merchant.owner_user_id,
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ class StoreTeamService:
|
||||
inviter: User,
|
||||
email: str,
|
||||
role_name: str,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
custom_permissions: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -148,6 +150,8 @@ class StoreTeamService:
|
||||
email=email,
|
||||
username=username,
|
||||
hashed_password=self.auth_manager.hash_password(temp_password),
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
role="store_member",
|
||||
is_active=False, # Will be activated when invitation accepted
|
||||
is_email_verified=False,
|
||||
|
||||
@@ -35,6 +35,7 @@ function merchantTeam() {
|
||||
showInviteModal: false,
|
||||
showEditModal: false,
|
||||
showRemoveModal: false,
|
||||
showViewModal: false,
|
||||
selectedMember: null,
|
||||
|
||||
// Invite form
|
||||
@@ -184,6 +185,14 @@ function merchantTeam() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -92,13 +92,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-800 dark:text-gray-200">
|
||||
<span x-text="member.first_name || ''"></span>
|
||||
<span x-text="member.last_name || ''"></span>
|
||||
<span x-show="!member.first_name && !member.last_name" x-text="member.email"></span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="member.email"
|
||||
x-show="member.first_name || member.last_name"></p>
|
||||
<p class="font-semibold text-gray-800 dark:text-gray-200"
|
||||
x-text="member.full_name"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="member.email"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -142,6 +138,12 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openViewModal(member)"
|
||||
class="p-1.5 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
:title="$t('tenancy.team.view_member')">
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<template x-if="member.is_owner">
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
<span x-html="$icon('shield-check', 'w-4 h-4')"></span>
|
||||
@@ -161,6 +163,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -372,6 +375,79 @@
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- ==================== VIEW MEMBER MODAL ==================== -->
|
||||
{% call modal('viewModal', _('tenancy.team.view_member'), 'showViewModal', size='md', show_footer=false) %}
|
||||
<div x-show="selectedMember" class="space-y-6">
|
||||
<!-- Member header -->
|
||||
<div class="flex items-center gap-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="w-14 h-14 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-lg font-semibold text-purple-600 dark:text-purple-300"
|
||||
x-text="selectedMember ? getInitials(selectedMember) : ''"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white"
|
||||
x-text="selectedMember?.full_name"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedMember?.email"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Information -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{{ _('tenancy.team.account_information') }}</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ _('tenancy.team.username') }}</label>
|
||||
<p class="text-sm font-mono text-gray-900 dark:text-gray-100" x-text="selectedMember?.username || '-'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ _('tenancy.team.role') }}</label>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedMember?.role || '-'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ _('tenancy.team.email_verified') }}</label>
|
||||
<span :class="selectedMember?.is_email_verified
|
||||
? 'px-2 py-0.5 text-xs font-semibold text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100'
|
||||
: 'px-2 py-0.5 text-xs font-semibold text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="selectedMember?.is_email_verified ? 'Verified' : 'Not verified'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ _('tenancy.team.last_login') }}</label>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedMember?.last_login ? formatDate(selectedMember.last_login) : 'Never'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ _('tenancy.team.account_created') }}</label>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedMember?.created_at ? formatDate(selectedMember.created_at) : '-'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Memberships -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{{ _('tenancy.team.store_roles') }}</h4>
|
||||
<div class="space-y-2">
|
||||
<template x-for="store in selectedMember?.stores || []" :key="store.store_id">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="store.store_name"></p>
|
||||
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300"
|
||||
x-text="store.role_name || 'Owner'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button @click="showViewModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
|
||||
{{ _('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
|
||||
Reference in New Issue
Block a user