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

@@ -7,7 +7,7 @@ Supports both auto-scraped (digital) and manually entered (offline) contacts.
import enum
from sqlalchemy import Boolean, Column, Enum, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -30,7 +30,7 @@ class ProspectContact(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True)
prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True)
contact_type = Column(Enum(ContactType), nullable=False)
contact_type = Column(String(20), nullable=False)
value = Column(String(500), nullable=False)
label = Column(String(100), nullable=True) # e.g., "info", "sales", "main"
source_url = Column(Text, nullable=True) # Page where contact was found

View File

@@ -261,22 +261,67 @@ class EnrichmentService:
return None
def scrape_contacts(self, db: Session, prospect: Prospect) -> list[ProspectContact]:
"""Scrape email and phone contacts from prospect's website."""
"""Scrape email and phone contacts from prospect's website.
Uses a two-phase approach:
1. Structured extraction from <a href="tel:..."> and <a href="mailto:..."> (high confidence)
2. Regex fallback for emails and international phone numbers (stricter filtering)
"""
from urllib.parse import unquote
domain = prospect.domain_name
if not domain or not prospect.has_website:
return []
scheme = "https" if prospect.uses_https else "http"
base_url = f"{scheme}://{domain}"
paths = ["", "/contact", "/kontakt", "/impressum", "/about"]
paths = ["", "/contact", "/kontakt", "/impressum", "/about", "/mentions-legales"]
email_pattern = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
phone_pattern = re.compile(r"(?:\+352|00352)?[\s.-]?\d{2,3}[\s.-]?\d{2,3}[\s.-]?\d{2,3}")
# Structured patterns (from <a href> tags)
tel_pattern = re.compile(r'href=["\']tel:([^"\'>\s]+)', re.IGNORECASE)
mailto_pattern = re.compile(r'href=["\']mailto:([^"\'>\s?]+)', re.IGNORECASE)
false_positive_domains = {"example.com", "email.com", "domain.com", "wordpress.org", "w3.org", "schema.org"}
found_emails = set()
found_phones = set()
contacts = []
# Regex fallback patterns
email_regex = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
# International phone: requires + prefix to avoid matching random digit sequences
phone_regex = re.compile(
r"\+\d{1,3}[\s.-]?\(?\d{1,4}\)?[\s.-]?\d{2,4}[\s.-]?\d{2,4}(?:[\s.-]?\d{2,4})?"
)
false_positive_domains = {
"example.com", "email.com", "domain.com", "wordpress.org",
"w3.org", "schema.org", "sentry.io", "googleapis.com",
}
found_emails: set[str] = set()
found_phones: set[str] = set()
contacts: list[ProspectContact] = []
def _add_email(email: str, url: str, source: str) -> None:
email = unquote(email).strip().lower()
email_domain = email.split("@")[1] if "@" in email else ""
if email_domain in false_positive_domains or email in found_emails:
return
found_emails.add(email)
contacts.append(ProspectContact(
prospect_id=prospect.id,
contact_type="email",
value=email,
source_url=url,
source_element=source,
))
def _add_phone(phone: str, url: str, source: str) -> None:
phone_clean = re.sub(r"[\s.()\-]", "", phone)
if len(phone_clean) < 10 or phone_clean in found_phones:
return
found_phones.add(phone_clean)
contacts.append(ProspectContact(
prospect_id=prospect.id,
contact_type="phone",
value=phone_clean,
source_url=url,
source_element=source,
))
session = requests.Session()
session.verify = False # noqa: SEC047 passive scan, not sending sensitive data
@@ -290,29 +335,22 @@ class EnrichmentService:
continue
html = response.text
for email in email_pattern.findall(html):
email_domain = email.split("@")[1].lower()
if email_domain not in false_positive_domains and email not in found_emails:
found_emails.add(email)
contacts.append(ProspectContact(
prospect_id=prospect.id,
contact_type="email",
value=email.lower(),
source_url=url,
source_element="regex",
))
# Phase 1: structured extraction from href attributes
for phone in tel_pattern.findall(html):
_add_phone(unquote(phone), url, "tel_href")
for email in mailto_pattern.findall(html):
_add_email(email, url, "mailto_href")
# Phase 2: regex fallback — strip SVG/script content first
text_html = re.sub(r"<(svg|script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
for email in email_regex.findall(text_html):
_add_email(email, url, "regex")
for phone in phone_regex.findall(text_html):
_add_phone(phone, url, "regex")
for phone in phone_pattern.findall(html):
phone_clean = re.sub(r"[\s.-]", "", phone)
if len(phone_clean) >= 8 and phone_clean not in found_phones:
found_phones.add(phone_clean)
contacts.append(ProspectContact(
prospect_id=prospect.id,
contact_type="phone",
value=phone_clean,
source_url=url,
source_element="regex",
))
except Exception as e: # noqa: EXC003
logger.debug("Contact scrape failed for %s%s: %s", domain, path, e)
@@ -321,21 +359,20 @@ class EnrichmentService:
# Save contacts (replace existing auto-scraped ones)
db.query(ProspectContact).filter(
ProspectContact.prospect_id == prospect.id,
ProspectContact.source_element == "regex",
ProspectContact.source_element.in_(["regex", "tel_href", "mailto_href"]),
).delete()
db.add_all(contacts)
# Mark first email and phone as primary
if contacts:
for c in contacts:
if c.contact_type == "email":
c.is_primary = True
break
for c in contacts:
if c.contact_type == "phone":
c.is_primary = True
break
for c in contacts:
if c.contact_type == "email":
c.is_primary = True
break
for c in contacts:
if c.contact_type == "phone":
c.is_primary = True
break
prospect.last_contact_scrape_at = datetime.now(UTC)
db.flush()

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 -->