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
|
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
|
||||||
|
|||||||
@@ -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,13 +359,12 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ── #}
|
|
||||||
<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="flex items-center text-sm">
|
||||||
<div class="relative w-8 h-8 mr-3 rounded-full flex-shrink-0">
|
<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"
|
<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'">
|
: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(row.member)"></span>
|
<span class="text-xs font-semibold" x-text="getInitials(member)"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div>
|
||||||
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="row.member.full_name"></p>
|
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="member.full_name"></p>
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="row.member.email"></p>
|
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="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>
|
|
||||||
{# 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>
|
</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">
|
|
||||||
<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>
|
</template>
|
||||||
|
</div>
|
||||||
</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)"
|
|
||||||
: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')">
|
:title="$t('tenancy.team.view_member')">
|
||||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
<template x-if="!member.is_owner">
|
||||||
<template x-if="row.type !== 'member'"><span></span></template>
|
<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"
|
||||||
{# 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">
|
||||||
|
<!-- Store header: name + status -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200" x-text="store.store_name"></p>
|
<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>
|
<p class="text-xs text-gray-400 font-mono" x-text="store.store_code"></p>
|
||||||
</div>
|
</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"
|
<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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user