refactor(tenancy): simplify team table + move actions to edit modal
Some checks failed
CI / ruff (push) Successful in 15s
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
CI / pytest (push) Has been cancelled

Reverts the expandable sub-row design back to a clean one-row-per-member
table. All per-store management now happens inside the edit modal.

Table: simple 4-column layout (Member | Stores & Roles | Status | Actions)
with view + edit buttons. Store badges show orange for pending stores.

Edit modal enhanced with per-store cards showing:
- Store name, code, and status badge (Active/Pending)
- Role dropdown + Update button (for active stores)
- Resend invitation button (for pending stores)
- Remove from store button
- "Remove from all stores" link at bottom

Removed: expandedMembers, flattenedRows, toggleMemberExpand,
resendStoreInvitation, resendInvitation (member-level).
Added: resendForStore, removeFromStore (work inside edit modal).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 21:08:36 +02:00
parent 0c6d8409c7
commit d685341b04
4 changed files with 201 additions and 224 deletions

View File

@@ -31,9 +31,6 @@ function merchantTeam() {
// Filters
storeFilter: '',
// Expanded member rows
expandedMembers: [],
// Modal states
showInviteModal: false,
showEditModal: false,
@@ -132,44 +129,18 @@ function merchantTeam() {
},
/**
* Flatten members + their stores into a single row list for table rendering.
* Each row is either {type:'member', member, key} or {type:'store', member, store, key}
* Resend invitation for a specific store (used inside edit modal)
*/
get flattenedRows() {
const rows = [];
for (const member of this.filteredMembers) {
rows.push({ type: 'member', member, key: `m-${member.user_id}` });
for (const store of member.stores) {
rows.push({ type: 'store', member, store, key: `s-${member.user_id}-${store.store_id}` });
}
}
return rows;
},
/**
* 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) {
async resendForStore(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);
this.showEditModal = false;
this.selectedMember = null;
await this.loadTeamData();
} catch (error) {
merchantTeamLog.error('Failed to resend invitation:', error);
@@ -179,6 +150,28 @@ function merchantTeam() {
}
},
/**
* Remove member from a specific store (used inside edit modal)
*/
async removeFromStore(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 from store:', storeId, 'user:', userId);
this.showEditModal = 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;
}
},
/**
* Open invite modal with reset form
*/
@@ -277,30 +270,6 @@ function merchantTeam() {
}
},
/**
* 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

View File

@@ -75,147 +75,79 @@
</select>
</div>
<!-- Members Table -->
<!-- Members Table -->
<div x-show="filteredMembers.length > 0">
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">{{ _('tenancy.team.member') }}</th>
<th class="px-4 py-3 w-36">{{ _('tenancy.team.role') }}</th>
<th class="px-4 py-3 w-28">{{ _('tenancy.team.status') }}</th>
<th class="px-4 py-3 w-40">{{ _('tenancy.team.actions') }}</th>
</tr>
</thead>
{{ table_header([_('tenancy.team.member'), _('tenancy.team.stores_and_roles'), _('tenancy.team.status'), _('tenancy.team.actions')]) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{# Flatten members + stores into a single row list for proper table rendering.
Alpine x-for on <template> inside <tbody> renders each <tr> as a direct child. #}
<template x-for="row in flattenedRows" :key="row.key">
{# ── Main member row ── #}
<tr x-show="row.type === 'member' || expandedMembers.includes(row.member.user_id)"
:class="row.type === 'member'
? 'text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer'
: 'bg-gray-50 dark:bg-gray-900/50 text-gray-600 dark:text-gray-400'"
@click="row.type === 'member' && toggleMemberExpand(row.member.user_id)">
{# Column 1: Member name OR Store name #}
<td class="px-4 py-3" :class="row.type === 'store' && 'py-2 pl-16'">
{# Member info #}
<template x-if="row.type === 'member'">
<div class="flex items-center text-sm">
<div class="relative w-8 h-8 mr-3 rounded-full flex-shrink-0">
<div class="flex items-center justify-center w-full h-full rounded-full"
:class="getMemberStatus(row.member) === 'active' ? 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'">
<span class="text-xs font-semibold" x-text="getInitials(row.member)"></span>
</div>
</div>
<div class="flex-1">
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="row.member.full_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="row.member.email"></p>
</div>
<div class="flex items-center gap-1 ml-2 text-xs text-gray-400">
<span x-html="$icon(expandedMembers.includes(row.member.user_id) ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
<span x-text="row.member.stores.length + ' store' + (row.member.stores.length !== 1 ? 's' : '')"></span>
<template x-for="member in filteredMembers" :key="member.user_id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<!-- Member -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative w-8 h-8 mr-3 rounded-full flex-shrink-0">
<div class="flex items-center justify-center w-full h-full rounded-full"
:class="getMemberStatus(member) === 'active' ? 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'">
<span class="text-xs font-semibold" x-text="getInitials(member)"></span>
</div>
</div>
</template>
{# Store info (sub-row) #}
<template x-if="row.type === 'store'">
<div class="flex items-center gap-2 text-sm">
<span x-html="$icon('shopping-bag', 'w-3.5 h-3.5 text-gray-400')"></span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="row.store.store_name"></span>
<span class="text-xs text-gray-400 font-mono" x-text="row.store.store_code"></span>
<div>
<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>
</template>
</div>
</td>
{# Column 2: Role #}
<td class="px-4 text-sm" :class="row.type === 'member' ? 'py-3' : 'py-2'">
{# Member-level role summary #}
<template x-if="row.type === 'member' && row.member.is_owner">
<span class="text-xs text-purple-600 dark:text-purple-400 font-medium">Owner</span>
</template>
<template x-if="row.type === 'member' && !row.member.is_owner && row.member.stores.length === 1">
<span class="text-xs text-gray-600 dark:text-gray-400" x-text="row.member.stores[0].role_name"></span>
</template>
<template x-if="row.type === 'member' && !row.member.is_owner && row.member.stores.length > 1">
<span class="text-xs text-gray-400">{{ _('tenancy.team.multiple_roles') }}</span>
</template>
{# Store-level role badge #}
<template x-if="row.type === 'store'">
<span class="px-2 py-0.5 text-xs rounded-full bg-purple-50 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300"
x-text="row.store.role_name || (row.member.is_owner ? 'Owner' : '{{ _('tenancy.team.no_role') }}')"></span>
</template>
<!-- Stores & Roles -->
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<template x-for="store in member.stores" :key="store.store_id">
<span class="px-2 py-1 text-xs rounded-full"
:class="store.is_pending ? 'bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'">
<span class="font-medium" x-text="store.store_name"></span>:
<span x-text="store.role_name || '{{ _('tenancy.team.no_role') }}'"></span>
</span>
</template>
</div>
</td>
{# Column 3: Status #}
<td class="px-4 text-sm" :class="row.type === 'member' ? 'py-3' : 'py-2'">
{# Member-level status #}
<template x-if="row.type === 'member' && row.member.is_owner">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<!-- Status -->
<td class="px-4 py-3 text-sm">
<template x-if="member.is_owner">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<span x-html="$icon('shield-check', 'w-3 h-3 mr-1')"></span>
{{ _('tenancy.team.owner') }}
</span>
</template>
<template x-if="row.type === 'member' && !row.member.is_owner && getMemberStatus(row.member) === 'active'">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('common.active') }}</span>
<template x-if="!member.is_owner && getMemberStatus(member) === 'active'">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('common.active') }}</span>
</template>
<template x-if="row.type === 'member' && !row.member.is_owner && getMemberStatus(row.member) === 'pending'">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">{{ _('common.pending') }}</span>
</template>
{# Store-level status #}
<template x-if="row.type === 'store' && row.store.is_pending">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-200">{{ _('common.pending') }}</span>
</template>
<template x-if="row.type === 'store' && !row.store.is_pending && row.store.is_active">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200">{{ _('common.active') }}</span>
<template x-if="!member.is_owner && getMemberStatus(member) === 'pending'">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">{{ _('common.pending') }}</span>
</template>
</td>
{# Column 4: Actions (4-slot grid) #}
<td class="px-4 text-sm" :class="row.type === 'member' ? 'py-3' : 'py-2'">
<div class="grid grid-cols-4 gap-1 w-32" @click.stop>
{# Slot 1: resend #}
<template x-if="row.type === 'store' && row.store.is_pending && !row.member.is_owner">
<button @click="resendStoreInvitation(row.store.store_id, row.member.user_id)"
:disabled="saving"
class="p-1 text-gray-400 hover:text-green-600 dark:hover:text-green-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
:title="$t('tenancy.team.resend_invitation')">
<span x-html="$icon('paper-airplane', 'w-3.5 h-3.5')"></span>
</button>
</template>
<template x-if="!(row.type === 'store' && row.store.is_pending && !row.member.is_owner)"><span></span></template>
{# Slot 2: view #}
<template x-if="row.type === 'member'">
<button @click="openViewModal(row.member)"
class="p-1 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 transition-colors"
:title="$t('tenancy.team.view_member')">
<span x-html="$icon('eye', 'w-4 h-4')"></span>
</button>
</template>
<template x-if="row.type !== 'member'"><span></span></template>
{# Slot 3: edit #}
<template x-if="row.type === 'member' && !row.member.is_owner">
<button @click="openEditModal(row.member)"
class="p-1 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
<!-- 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">
<button @click="openEditModal(member)"
class="p-1.5 text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
:title="$t('tenancy.team.edit_member')">
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
</template>
<template x-if="!(row.type === 'member' && !row.member.is_owner)"><span></span></template>
{# Slot 4: remove #}
<template x-if="row.type === 'store' && !row.member.is_owner">
<button @click="removeMember(row.store.store_id, row.member.user_id)"
:disabled="saving"
class="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
:title="$t('tenancy.team.remove_member')">
<span x-html="$icon('x-circle', 'w-3.5 h-3.5')"></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>
</span>
</template>
<template x-if="!(row.type === 'store' && !row.member.is_owner)"><span></span></template>
</div>
</td>
</tr>
@@ -325,7 +257,7 @@
{% call modal('editModal', _('tenancy.team.edit_member'), 'showEditModal', size='md', show_footer=false) %}
<template x-if="selectedMember">
<div class="space-y-4">
<!-- Profile fields -->
<!-- Section 1: Personal Info -->
<div class="space-y-3">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.personal_info') }}</h4>
<div class="grid grid-cols-2 gap-3">
@@ -358,18 +290,28 @@
</div>
</div>
<!-- Per-store role management -->
<!-- Section 2: Store Memberships -->
<div class="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.store_roles') }}</h4>
<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 class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg space-y-2">
<!-- Store header: name + status -->
<div class="flex items-center justify-between">
<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-0.5 rounded-full text-xs font-medium"
:class="store.is_pending
? 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-200'
: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200'"
x-text="store.is_pending ? '{{ _('common.pending') }}' : '{{ _('common.active') }}'"></span>
</div>
<div class="flex items-center gap-2">
<!-- Active: role dropdown + update -->
<div x-show="!store.is_pending" class="flex items-center gap-2">
<select x-model="store.role_name"
class="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none">
class="flex-1 px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none">
<template x-for="role in roleOptions" :key="role.value">
<option :value="role.value" :selected="role.value === store.role_name" x-text="role.label"></option>
</template>
@@ -381,8 +323,37 @@
<span x-show="!saving">{{ _('common.update') }}</span>
</button>
</div>
<!-- Actions row: resend (pending) + remove -->
<div class="flex items-center justify-between">
<!-- Resend (pending only) -->
<button x-show="store.is_pending"
@click="resendForStore(store.store_id, selectedMember.user_id)"
:disabled="saving"
class="inline-flex items-center gap-1 px-2 py-1 text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors">
<span x-html="$icon('paper-airplane', 'w-3.5 h-3.5')"></span>
{{ _('tenancy.team.resend_invitation') }}
</button>
<span x-show="!store.is_pending"></span>
<!-- Remove from store -->
<button @click="removeFromStore(store.store_id, selectedMember.user_id)"
:disabled="saving"
class="inline-flex items-center gap-1 px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors">
<span x-html="$icon('x-circle', 'w-3.5 h-3.5')"></span>
{{ _('common.remove') }}
</button>
</div>
</div>
</template>
<!-- Remove from all stores link -->
<div x-show="selectedMember?.stores?.length > 1" class="text-center pt-2">
<button @click="showEditModal = false; openRemoveModal(selectedMember)"
class="text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 underline">
{{ _('tenancy.team.remove_from_all_stores') }}
</button>
</div>
</div>
<!-- Close button -->