refactor(tenancy): simplify team table + move actions to edit modal
Some checks failed
Some checks failed
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:
@@ -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
|
||||
|
||||
@@ -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,13 +359,12 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'">
|
||||
<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(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>
|
||||
: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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
</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>
|
||||
<!-- 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"
|
||||
<!-- 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>
|
||||
<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"
|
||||
<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 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>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
|
||||
Reference in New Issue
Block a user