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 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 sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -30,7 +30,7 @@ class ProspectContact(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, 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) value = Column(String(500), nullable=False)
label = Column(String(100), nullable=True) # e.g., "info", "sales", "main" label = Column(String(100), nullable=True) # e.g., "info", "sales", "main"
source_url = Column(Text, nullable=True) # Page where contact was found source_url = Column(Text, nullable=True) # Page where contact was found

View File

@@ -261,22 +261,67 @@ class EnrichmentService:
return None return None
def scrape_contacts(self, db: Session, prospect: Prospect) -> list[ProspectContact]: 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 domain = prospect.domain_name
if not domain or not prospect.has_website: if not domain or not prospect.has_website:
return [] return []
scheme = "https" if prospect.uses_https else "http" scheme = "https" if prospect.uses_https else "http"
base_url = f"{scheme}://{domain}" 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,}") # Structured patterns (from <a href> tags)
phone_pattern = re.compile(r"(?:\+352|00352)?[\s.-]?\d{2,3}[\s.-]?\d{2,3}[\s.-]?\d{2,3}") 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"} # Regex fallback patterns
found_emails = set() email_regex = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
found_phones = set() # International phone: requires + prefix to avoid matching random digit sequences
contacts = [] 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 = requests.Session()
session.verify = False # noqa: SEC047 passive scan, not sending sensitive data session.verify = False # noqa: SEC047 passive scan, not sending sensitive data
@@ -290,29 +335,22 @@ class EnrichmentService:
continue continue
html = response.text html = response.text
for email in email_pattern.findall(html): # Phase 1: structured extraction from href attributes
email_domain = email.split("@")[1].lower() for phone in tel_pattern.findall(html):
if email_domain not in false_positive_domains and email not in found_emails: _add_phone(unquote(phone), url, "tel_href")
found_emails.add(email)
contacts.append(ProspectContact( for email in mailto_pattern.findall(html):
prospect_id=prospect.id, _add_email(email, url, "mailto_href")
contact_type="email",
value=email.lower(), # Phase 2: regex fallback — strip SVG/script content first
source_url=url, text_html = re.sub(r"<(svg|script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
source_element="regex",
)) 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 except Exception as e: # noqa: EXC003
logger.debug("Contact scrape failed for %s%s: %s", domain, path, e) 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) # Save contacts (replace existing auto-scraped ones)
db.query(ProspectContact).filter( db.query(ProspectContact).filter(
ProspectContact.prospect_id == prospect.id, ProspectContact.prospect_id == prospect.id,
ProspectContact.source_element == "regex", ProspectContact.source_element.in_(["regex", "tel_href", "mailto_href"]),
).delete() ).delete()
db.add_all(contacts) db.add_all(contacts)
# Mark first email and phone as primary # Mark first email and phone as primary
if contacts: for c in contacts:
for c in contacts: if c.contact_type == "email":
if c.contact_type == "email": c.is_primary = True
c.is_primary = True break
break for c in contacts:
for c in contacts: if c.contact_type == "phone":
if c.contact_type == "phone": c.is_primary = True
c.is_primary = True break
break
prospect.last_contact_scrape_at = datetime.now(UTC) prospect.last_contact_scrape_at = datetime.now(UTC)
db.flush() db.flush()

View File

@@ -31,9 +31,6 @@ function merchantTeam() {
// Filters // Filters
storeFilter: '', storeFilter: '',
// Expanded member rows
expandedMembers: [],
// Modal states // Modal states
showInviteModal: false, showInviteModal: false,
showEditModal: false, showEditModal: false,
@@ -132,44 +129,18 @@ function merchantTeam() {
}, },
/** /**
* Flatten members + their stores into a single row list for table rendering. * Resend invitation for a specific store (used inside edit modal)
* Each row is either {type:'member', member, key} or {type:'store', member, store, key}
*/ */
get flattenedRows() { async resendForStore(storeId, userId) {
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) {
this.saving = true; this.saving = true;
try { try {
await apiClient.post( await apiClient.post(
`/merchants/account/team/stores/${storeId}/members/${userId}/resend` `/merchants/account/team/stores/${storeId}/members/${userId}/resend`
); );
Utils.showToast(I18n.t('tenancy.messages.invitation_resent'), 'success'); Utils.showToast(I18n.t('tenancy.messages.invitation_resent'), 'success');
merchantTeamLog.info('Resent invitation for store:', storeId, 'user:', userId); merchantTeamLog.info('Resent invitation for store:', storeId, 'user:', userId);
this.showEditModal = false;
this.selectedMember = null;
await this.loadTeamData(); await this.loadTeamData();
} catch (error) { } catch (error) {
merchantTeamLog.error('Failed to resend invitation:', 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 * 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 * Update member role for a specific store

View File

@@ -75,147 +75,79 @@
</select> </select>
</div> </div>
<!-- Members Table -->
<!-- Members Table --> <!-- Members Table -->
<div x-show="filteredMembers.length > 0"> <div x-show="filteredMembers.length > 0">
{% call table_wrapper() %} {% call table_wrapper() %}
<thead> {{ table_header([_('tenancy.team.member'), _('tenancy.team.stores_and_roles'), _('tenancy.team.status'), _('tenancy.team.actions')]) }}
<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>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <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. <template x-for="member in filteredMembers" :key="member.user_id">
Alpine x-for on <template> inside <tbody> renders each <tr> as a direct child. #} <tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<template x-for="row in flattenedRows" :key="row.key"> <!-- Member -->
<td class="px-4 py-3">
{# ── Main member row ── #} <div class="flex items-center text-sm">
<tr x-show="row.type === 'member' || expandedMembers.includes(row.member.user_id)" <div class="relative w-8 h-8 mr-3 rounded-full flex-shrink-0">
:class="row.type === 'member' <div class="flex items-center justify-center w-full h-full rounded-full"
? 'text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer' :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'">
: 'bg-gray-50 dark:bg-gray-900/50 text-gray-600 dark:text-gray-400'" <span class="text-xs font-semibold" x-text="getInitials(member)"></span>
@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>
</div> </div>
</div> </div>
</template> <div>
{# Store info (sub-row) #} <p class="font-semibold text-gray-800 dark:text-gray-200" x-text="member.full_name"></p>
<template x-if="row.type === 'store'"> <p class="text-xs text-gray-600 dark:text-gray-400" x-text="member.email"></p>
<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> </div>
</template> </div>
</td> </td>
{# Column 2: Role #} <!-- Stores & Roles -->
<td class="px-4 text-sm" :class="row.type === 'member' ? 'py-3' : 'py-2'"> <td class="px-4 py-3">
{# Member-level role summary #} <div class="flex flex-wrap gap-1">
<template x-if="row.type === 'member' && row.member.is_owner"> <template x-for="store in member.stores" :key="store.store_id">
<span class="text-xs text-purple-600 dark:text-purple-400 font-medium">Owner</span> <span class="px-2 py-1 text-xs rounded-full"
</template> :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'">
<template x-if="row.type === 'member' && !row.member.is_owner && row.member.stores.length === 1"> <span class="font-medium" x-text="store.store_name"></span>:
<span class="text-xs text-gray-600 dark:text-gray-400" x-text="row.member.stores[0].role_name"></span> <span x-text="store.role_name || '{{ _('tenancy.team.no_role') }}'"></span>
</template> </span>
<template x-if="row.type === 'member' && !row.member.is_owner && row.member.stores.length > 1"> </template>
<span class="text-xs text-gray-400">{{ _('tenancy.team.multiple_roles') }}</span> </div>
</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>
</td> </td>
{# Column 3: Status #} <!-- Status -->
<td class="px-4 text-sm" :class="row.type === 'member' ? 'py-3' : 'py-2'"> <td class="px-4 py-3 text-sm">
{# Member-level status #} <template x-if="member.is_owner">
<template x-if="row.type === 'member' && row.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 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">
<span x-html="$icon('shield-check', 'w-3 h-3 mr-1')"></span> <span x-html="$icon('shield-check', 'w-3 h-3 mr-1')"></span>
{{ _('tenancy.team.owner') }} {{ _('tenancy.team.owner') }}
</span> </span>
</template> </template>
<template x-if="row.type === 'member' && !row.member.is_owner && getMemberStatus(row.member) === 'active'"> <template x-if="!member.is_owner && getMemberStatus(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> <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>
<template x-if="row.type === 'member' && !row.member.is_owner && getMemberStatus(row.member) === 'pending'"> <template x-if="!member.is_owner && getMemberStatus(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> <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>
{# 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> </template>
</td> </td>
{# Column 4: Actions (4-slot grid) #} <!-- Actions -->
<td class="px-4 text-sm" :class="row.type === 'member' ? 'py-3' : 'py-2'"> <td class="px-4 py-3 text-sm">
<div class="grid grid-cols-4 gap-1 w-32" @click.stop> <div class="flex items-center gap-2">
{# Slot 1: resend #} <button @click="openViewModal(member)"
<template x-if="row.type === 'store' && row.store.is_pending && !row.member.is_owner"> 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"
<button @click="resendStoreInvitation(row.store.store_id, row.member.user_id)" :title="$t('tenancy.team.view_member')">
:disabled="saving" <span x-html="$icon('eye', 'w-4 h-4')"></span>
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" </button>
:title="$t('tenancy.team.resend_invitation')"> <template x-if="!member.is_owner">
<span x-html="$icon('paper-airplane', 'w-3.5 h-3.5')"></span> <button @click="openEditModal(member)"
</button> 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"
</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"
:title="$t('tenancy.team.edit_member')"> :title="$t('tenancy.team.edit_member')">
<span x-html="$icon('pencil', 'w-4 h-4')"></span> <span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button> </button>
</template> </template>
<template x-if="!(row.type === 'member' && !row.member.is_owner)"><span></span></template> <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">
{# Slot 4: remove #} <span x-html="$icon('shield-check', 'w-4 h-4')"></span>
<template x-if="row.type === 'store' && !row.member.is_owner"> </span>
<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> </template>
<template x-if="!(row.type === 'store' && !row.member.is_owner)"><span></span></template>
</div> </div>
</td> </td>
</tr> </tr>
@@ -325,7 +257,7 @@
{% call modal('editModal', _('tenancy.team.edit_member'), 'showEditModal', size='md', show_footer=false) %} {% call modal('editModal', _('tenancy.team.edit_member'), 'showEditModal', size='md', show_footer=false) %}
<template x-if="selectedMember"> <template x-if="selectedMember">
<div class="space-y-4"> <div class="space-y-4">
<!-- Profile fields --> <!-- Section 1: Personal Info -->
<div class="space-y-3"> <div class="space-y-3">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.personal_info') }}</h4> <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"> <div class="grid grid-cols-2 gap-3">
@@ -358,18 +290,28 @@
</div> </div>
</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"> <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> <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"> <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 class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg space-y-2">
<div> <!-- Store header: name + status -->
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="store.store_name"></p> <div class="flex items-center justify-between">
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p> <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>
<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" <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"> <template x-for="role in roleOptions" :key="role.value">
<option :value="role.value" :selected="role.value === store.role_name" x-text="role.label"></option> <option :value="role.value" :selected="role.value === store.role_name" x-text="role.label"></option>
</template> </template>
@@ -381,8 +323,37 @@
<span x-show="!saving">{{ _('common.update') }}</span> <span x-show="!saving">{{ _('common.update') }}</span>
</button> </button>
</div> </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> </div>
</template> </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> </div>
<!-- Close button --> <!-- Close button -->