Add new changes to client profile and related files

This commit is contained in:
2026-02-05 14:03:56 -07:00
parent 896c99d586
commit ea682d8cd9
11 changed files with 2761 additions and 243 deletions

View File

@@ -9,6 +9,7 @@ from app.core.database import get_db
from app.core.security import security from app.core.security import security
from app.core.config import get_settings from app.core.config import get_settings
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.models.tenant import Tenant
settings = get_settings() settings = get_settings()
@@ -55,3 +56,20 @@ async def get_current_active_superuser(
status_code=403, detail="The user doesn't have enough privileges" status_code=403, detail="The user doesn't have enough privileges"
) )
return current_user return current_user
async def get_current_tenant(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> Tenant:
"""Obtener el tenant del usuario actual."""
result = await db.execute(select(Tenant).where(Tenant.id == current_user.tenant_id))
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
return tenant

View File

@@ -0,0 +1,15 @@
"""Schemas package initialization."""
from .client_profile import (
ClientProfileCreate,
ClientProfileUpdate,
ClientProfileResponse,
ClientProfileSummary
)
__all__ = [
"ClientProfileCreate",
"ClientProfileUpdate",
"ClientProfileResponse",
"ClientProfileSummary"
]

View File

@@ -0,0 +1,202 @@
"""
Client Profile Schemas - ServiceManagerWeb
Esquemas de validación para el perfil empresarial de clientes
"""
from pydantic import BaseModel, Field, validator, EmailStr
from typing import Optional
from decimal import Decimal
from datetime import datetime
import uuid
class ClientProfileBase(BaseModel):
"""Schema base para ClientProfile."""
# === INFORMACIÓN GENERAL ===
business_name: Optional[str] = Field(None, max_length=255, description="Razón social")
commercial_name: Optional[str] = Field(None, max_length=255, description="Nombre comercial")
client_code: Optional[str] = Field(None, max_length=50, description="Clave de cliente")
client_type: Optional[str] = Field(None, max_length=50, description="Tipo de cliente")
rfc: Optional[str] = Field(None, max_length=13, description="RFC (México)")
tax_id: Optional[str] = Field(None, max_length=50, description="ID fiscal general")
# === UBICACIÓN ===
country: Optional[str] = Field(None, max_length=100, description="País")
state: Optional[str] = Field(None, max_length=100, description="Estado/Provincia")
city: Optional[str] = Field(None, max_length=100, description="Ciudad")
address: Optional[str] = Field(None, description="Dirección completa")
external_number: Optional[str] = Field(None, max_length=20, description="Número exterior")
internal_number: Optional[str] = Field(None, max_length=20, description="Número interior")
postal_code: Optional[str] = Field(None, max_length=10, description="Código postal")
neighborhood: Optional[str] = Field(None, max_length=100, description="Colonia")
# === CONTACTO ===
main_phone: Optional[str] = Field(None, max_length=20, description="Teléfono principal")
secondary_phone: Optional[str] = Field(None, max_length=20, description="Teléfono secundario")
direct_phone: Optional[str] = Field(None, max_length=20, description="Teléfono directo")
phone_extension: Optional[str] = Field(None, max_length=10, description="Extensión")
fax: Optional[str] = Field(None, max_length=20, description="Fax")
# === INFORMACIÓN ADICIONAL ===
business_hours: Optional[str] = Field(None, max_length=255, description="Horario de atención")
website: Optional[str] = Field(None, max_length=255, description="Página web")
main_email: Optional[EmailStr] = Field(None, description="Email principal")
billing_email: Optional[EmailStr] = Field(None, description="Email de facturación")
# === MARKETING ===
advertising_medium: Optional[str] = Field(None, max_length=255, description="Medio de publicidad")
nationality: Optional[str] = Field(None, max_length=100, description="Nacionalidad")
# === CONFIGURACIÓN EMPRESARIAL ===
logo_url: Optional[str] = Field(None, max_length=500, description="URL del logo")
company_representative: Optional[str] = Field(None, max_length=255, description="Representante de empresa")
legal_representative: Optional[str] = Field(None, max_length=255, description="Representante legal")
# === FINANZAS/FACTURACIÓN ===
credit_limit: Optional[Decimal] = Field(None, description="Límite de crédito")
payment_terms: Optional[str] = Field(None, max_length=100, description="Términos de pago")
preferred_currency: str = Field("MXN", max_length=3, description="Moneda preferida")
# === METADATOS ===
send_to_billing: bool = Field(False, description="Enviar a facturación")
is_active_client: bool = Field(True, description="Cliente activo")
is_prospect: bool = Field(False, description="Es prospecto")
notes: Optional[str] = Field(None, description="Notas adicionales")
@validator('rfc')
def validate_rfc(cls, v):
"""Validar formato de RFC mexicano."""
if v is None:
return v
v = v.strip().upper()
if len(v) < 10 or len(v) > 13:
raise ValueError('RFC debe tener entre 10 y 13 caracteres')
# Validación básica de formato RFC
import re
rfc_pattern = r'^[A-ZÑ&]{3,4}[0-9]{6}[A-Z0-9]{3}$'
if not re.match(rfc_pattern, v):
raise ValueError('Formato de RFC inválido')
return v
@validator('postal_code')
def validate_postal_code(cls, v):
"""Validar código postal."""
if v is None:
return v
v = v.strip()
if not v.isdigit() or len(v) != 5:
raise ValueError('Código postal debe tener 5 dígitos')
return v
@validator('website')
def validate_website(cls, v):
"""Validar formato de sitio web."""
if v is None:
return v
v = v.strip()
if not v.startswith(('http://', 'https://')):
v = f"https://{v}"
import re
url_pattern = r'^https?://.+\..+'
if not re.match(url_pattern, v):
raise ValueError('Formato de sitio web inválido')
return v
@validator('preferred_currency')
def validate_currency(cls, v):
"""Validar código de moneda."""
valid_currencies = ['MXN', 'USD', 'EUR', 'GBP', 'CAD']
if v not in valid_currencies:
raise ValueError(f'Moneda debe ser una de: {", ".join(valid_currencies)}')
return v
class ClientProfileCreate(ClientProfileBase):
"""Schema para crear un perfil de cliente."""
pass
class ClientProfileUpdate(ClientProfileBase):
"""Schema para actualizar un perfil de cliente."""
pass
class ClientProfileResponse(ClientProfileBase):
"""Schema de respuesta para ClientProfile."""
id: uuid.UUID
tenant_id: uuid.UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
@property
def full_address(self) -> str:
"""Dirección completa formateada."""
address_parts = []
if self.address:
address_parts.append(self.address)
if self.external_number:
if self.internal_number:
address_parts.append(f"#{self.external_number}-{self.internal_number}")
else:
address_parts.append(f"#{self.external_number}")
if self.neighborhood:
address_parts.append(f"Col. {self.neighborhood}")
if self.city and self.state:
address_parts.append(f"{self.city}, {self.state}")
if self.postal_code:
address_parts.append(f"C.P. {self.postal_code}")
if self.country:
address_parts.append(self.country)
return ", ".join(address_parts)
@property
def display_name(self) -> str:
"""Nombre para mostrar."""
return self.commercial_name or self.business_name or "Sin nombre"
class ClientProfileSummary(BaseModel):
"""Schema resumido para listados."""
id: uuid.UUID
tenant_id: uuid.UUID
business_name: Optional[str]
commercial_name: Optional[str]
rfc: Optional[str]
client_code: Optional[str]
city: Optional[str]
state: Optional[str]
main_phone: Optional[str]
main_email: Optional[str]
is_active_client: bool
is_prospect: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
@property
def display_name(self) -> str:
"""Nombre para mostrar."""
return self.commercial_name or self.business_name or "Sin nombre"

View File

@@ -0,0 +1,299 @@
"""
Client Profile Endpoints - ServiceManagerWeb
Endpoints para gestión del perfil empresarial de clientes
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from typing import Optional
from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
from app.api.schemas.client_profile import (
ClientProfileCreate,
ClientProfileUpdate,
ClientProfileResponse,
ClientProfileSummary
)
from app.models.user import User
from app.models.tenant import Tenant
from app.models.client_profile import ClientProfile
import uuid
router = APIRouter()
@router.get("/", response_model=ClientProfileResponse)
async def get_current_client_profile(
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener el perfil empresarial del tenant actual.
**Permisos**: CLIENT_ADMIN, CLIENT_USER
"""
# Solo clientes pueden acceder
if current_user.role not in ['CLIENT_ADMIN', 'CLIENT_USER']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo los clientes pueden acceder al perfil empresarial"
)
# Buscar perfil existente
result = await db.execute(
select(ClientProfile).where(ClientProfile.tenant_id == current_tenant.id)
)
profile = result.scalar_one_or_none()
if not profile:
# Si no existe, crear uno vacío
profile = ClientProfile(tenant_id=current_tenant.id)
db.add(profile)
await db.commit()
await db.refresh(profile)
return profile
@router.post("/", response_model=ClientProfileResponse)
async def create_or_update_client_profile(
profile_data: ClientProfileCreate,
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Crear o actualizar el perfil empresarial del tenant actual.
**Permisos**: CLIENT_ADMIN
"""
# Solo CLIENT_ADMIN puede modificar el perfil
if current_user.role != 'CLIENT_ADMIN':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo los administradores de cliente pueden modificar el perfil empresarial"
)
# Buscar perfil existente
result = await db.execute(
select(ClientProfile).where(ClientProfile.tenant_id == current_tenant.id)
)
existing_profile = result.scalar_one_or_none()
if existing_profile:
# Actualizar perfil existente
update_data = profile_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(existing_profile, field, value)
profile = existing_profile
else:
# Crear nuevo perfil
profile = ClientProfile(
tenant_id=current_tenant.id,
**profile_data.dict()
)
db.add(profile)
await db.commit()
await db.refresh(profile)
return profile
@router.patch("/", response_model=ClientProfileResponse)
async def update_client_profile(
profile_data: ClientProfileUpdate,
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Actualizar parcialmente el perfil empresarial del tenant actual.
**Permisos**: CLIENT_ADMIN
"""
# Solo CLIENT_ADMIN puede modificar el perfil
if current_user.role != 'CLIENT_ADMIN':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo los administradores de cliente pueden modificar el perfil empresarial"
)
# Buscar perfil existente
result = await db.execute(
select(ClientProfile).where(ClientProfile.tenant_id == current_tenant.id)
)
profile = result.scalar_one_or_none()
if not profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Perfil empresarial no encontrado"
)
# Actualizar solo campos proporcionados
update_data = profile_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(profile, field, value)
await db.commit()
await db.refresh(profile)
return profile
@router.delete("/")
async def delete_client_profile(
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Eliminar el perfil empresarial del tenant actual.
**Permisos**: CLIENT_ADMIN
"""
# Solo CLIENT_ADMIN puede eliminar el perfil
if current_user.role != 'CLIENT_ADMIN':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo los administradores de cliente pueden eliminar el perfil empresarial"
)
# Buscar perfil existente
result = await db.execute(
select(ClientProfile).where(ClientProfile.tenant_id == current_tenant.id)
)
profile = result.scalar_one_or_none()
if not profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Perfil empresarial no encontrado"
)
await db.delete(profile)
await db.commit()
return {"message": "Perfil empresarial eliminado exitosamente"}
# === ENDPOINTS ADMINISTRATIVOS (Solo para ADMIN y SUPPORT_MANAGER) ===
@router.get("/admin/list", response_model=list[ClientProfileSummary])
async def list_all_client_profiles(
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Listar todos los perfiles empresariales (solo para administradores).
**Permisos**: ADMIN, SUPPORT_MANAGER
"""
# Solo personal interno puede ver todos los perfiles
if current_user.role not in ['ADMIN', 'SUPPORT_MANAGER']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Acceso denegado"
)
query = select(ClientProfile)
# Filtro de búsqueda
if search:
search_filter = or_(
ClientProfile.business_name.ilike(f"%{search}%"),
ClientProfile.commercial_name.ilike(f"%{search}%"),
ClientProfile.rfc.ilike(f"%{search}%"),
ClientProfile.client_code.ilike(f"%{search}%")
)
query = query.where(search_filter)
# Paginación
query = query.offset(skip).limit(limit)
query = query.order_by(ClientProfile.created_at.desc())
result = await db.execute(query)
profiles = result.scalars().all()
return profiles
@router.get("/admin/{tenant_id}", response_model=ClientProfileResponse)
async def get_client_profile_by_tenant(
tenant_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Obtener perfil empresarial de un tenant específico (solo para administradores).
**Permisos**: ADMIN, SUPPORT_MANAGER
"""
# Solo personal interno puede ver perfiles de otros tenants
if current_user.role not in ['ADMIN', 'SUPPORT_MANAGER']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Acceso denegado"
)
result = await db.execute(
select(ClientProfile).where(ClientProfile.tenant_id == tenant_id)
)
profile = result.scalar_one_or_none()
if not profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Perfil empresarial no encontrado"
)
return profile
@router.patch("/admin/{tenant_id}", response_model=ClientProfileResponse)
async def update_client_profile_by_admin(
tenant_id: uuid.UUID,
profile_data: ClientProfileUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Actualizar perfil empresarial de un tenant específico (solo para administradores).
**Permisos**: ADMIN, SUPPORT_MANAGER
"""
# Solo personal interno puede modificar perfiles de otros tenants
if current_user.role not in ['ADMIN', 'SUPPORT_MANAGER']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Acceso denegado"
)
result = await db.execute(
select(ClientProfile).where(ClientProfile.tenant_id == tenant_id)
)
profile = result.scalar_one_or_none()
if not profile:
# Crear perfil si no existe
profile = ClientProfile(tenant_id=tenant_id, **profile_data.dict(exclude_unset=True))
db.add(profile)
else:
# Actualizar perfil existente
update_data = profile_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(profile, field, value)
await db.commit()
await db.refresh(profile)
return profile

View File

@@ -6,7 +6,7 @@ Router principal para la API v1
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile
api_router = APIRouter() api_router = APIRouter()
@@ -54,6 +54,9 @@ api_router.include_router(
tags=["tickets"] tags=["tickets"]
) )
# Ensure FastAPI is installed in the environment # Client Profile routes
# If not, install it using: api_router.include_router(
# pip install fastapi client_profile.router,
prefix="/client-profile",
tags=["client-profile"]
)

View File

@@ -0,0 +1,19 @@
"""Models package initialization."""
from .user import User
from .tenant import Tenant
from .ticket import Ticket
from .comment import TicketComment
from .system import System
from .category import Category
from .client_profile import ClientProfile
__all__ = [
"User",
"Tenant",
"Ticket",
"TicketComment",
"System",
"Category",
"ClientProfile"
]

View File

@@ -0,0 +1,120 @@
"""
Client Profile Model - ServiceManagerWeb
Modelo para perfil empresarial de clientes
Almacena información detallada de la empresa cliente
"""
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import Optional
import uuid
from datetime import datetime
from app.core.database import Base
class ClientProfile(Base):
"""Modelo de Perfil de Cliente Empresarial."""
__tablename__ = "client_profiles"
# Relación con tenant (uno a uno)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True
)
# === INFORMACIÓN GENERAL ===
business_name: Mapped[Optional[str]] = mapped_column(String(255)) # Razón social
commercial_name: Mapped[Optional[str]] = mapped_column(String(255)) # Nombre comercial
client_code: Mapped[Optional[str]] = mapped_column(String(50)) # Clave de cliente
client_type: Mapped[Optional[str]] = mapped_column(String(50)) # Tipo de cliente
rfc: Mapped[Optional[str]] = mapped_column(String(13)) # RFC México
tax_id: Mapped[Optional[str]] = mapped_column(String(50)) # ID fiscal general
# === UBICACIÓN ===
country: Mapped[Optional[str]] = mapped_column(String(100))
state: Mapped[Optional[str]] = mapped_column(String(100))
city: Mapped[Optional[str]] = mapped_column(String(100))
address: Mapped[Optional[str]] = mapped_column(Text)
external_number: Mapped[Optional[str]] = mapped_column(String(20))
internal_number: Mapped[Optional[str]] = mapped_column(String(20))
postal_code: Mapped[Optional[str]] = mapped_column(String(10))
neighborhood: Mapped[Optional[str]] = mapped_column(String(100))
# === CONTACTO ===
main_phone: Mapped[Optional[str]] = mapped_column(String(20))
secondary_phone: Mapped[Optional[str]] = mapped_column(String(20))
direct_phone: Mapped[Optional[str]] = mapped_column(String(20))
phone_extension: Mapped[Optional[str]] = mapped_column(String(10))
fax: Mapped[Optional[str]] = mapped_column(String(20))
# === INFORMACIÓN ADICIONAL ===
business_hours: Mapped[Optional[str]] = mapped_column(String(255))
website: Mapped[Optional[str]] = mapped_column(String(255))
main_email: Mapped[Optional[str]] = mapped_column(String(320))
billing_email: Mapped[Optional[str]] = mapped_column(String(320))
# === MARKETING ===
advertising_medium: Mapped[Optional[str]] = mapped_column(String(255))
nationality: Mapped[Optional[str]] = mapped_column(String(100))
# === CONFIGURACIÓN EMPRESARIAL ===
logo_url: Mapped[Optional[str]] = mapped_column(String(500))
company_representative: Mapped[Optional[str]] = mapped_column(String(255)) # Encargado/Representante
legal_representative: Mapped[Optional[str]] = mapped_column(String(255))
# === FINANZAS/FACTURACIÓN ===
credit_limit: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
payment_terms: Mapped[Optional[str]] = mapped_column(String(100))
preferred_currency: Mapped[str] = mapped_column(String(3), default="MXN")
# === METADATOS ===
send_to_billing: Mapped[bool] = mapped_column(Boolean, default=False)
is_active_client: Mapped[bool] = mapped_column(Boolean, default=True)
is_prospect: Mapped[bool] = mapped_column(Boolean, default=False)
notes: Mapped[Optional[str]] = mapped_column(Text)
# === RELACIONES ===
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="client_profile")
def __repr__(self) -> str:
return f"<ClientProfile(tenant_id={self.tenant_id}, business_name='{self.business_name}')>"
@property
def full_address(self) -> str:
"""Dirección completa formateada."""
address_parts = []
if self.address:
address_parts.append(self.address)
if self.external_number:
if self.internal_number:
address_parts.append(f"#{self.external_number}-{self.internal_number}")
else:
address_parts.append(f"#{self.external_number}")
if self.neighborhood:
address_parts.append(f"Col. {self.neighborhood}")
if self.city and self.state:
address_parts.append(f"{self.city}, {self.state}")
if self.postal_code:
address_parts.append(f"C.P. {self.postal_code}")
if self.country:
address_parts.append(self.country)
return ", ".join(address_parts)
@property
def display_name(self) -> str:
"""Nombre para mostrar (comercial o razón social)."""
return self.commercial_name or self.business_name or "Sin nombre"

View File

@@ -54,6 +54,7 @@ class Tenant(Base):
users: Mapped[List["User"]] = relationship("User", back_populates="tenant") users: Mapped[List["User"]] = relationship("User", back_populates="tenant")
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant") tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant")
categories: Mapped[List["Category"]] = relationship("Category", back_populates="tenant") # ✅ CORREGIDO: Era "TicketCategory" categories: Mapped[List["Category"]] = relationship("Category", back_populates="tenant") # ✅ CORREGIDO: Era "TicketCategory"
client_profile: Mapped[Optional["ClientProfile"]] = relationship("ClientProfile", back_populates="tenant", uselist=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>" return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"

View File

@@ -0,0 +1,102 @@
"""Add client_profiles table
Revision ID: 13362e8c493a
Revises: 48c43e9204c3
Create Date: 2026-02-05 20:11:52.534918
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '13362e8c493a'
down_revision = '48c43e9204c3'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create client_profiles table
op.create_table('client_profiles',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, default=sa.text('gen_random_uuid()')),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
# === INFORMACIÓN GENERAL ===
sa.Column('business_name', sa.String(length=255), nullable=True),
sa.Column('commercial_name', sa.String(length=255), nullable=True),
sa.Column('client_code', sa.String(length=50), nullable=True),
sa.Column('client_type', sa.String(length=50), nullable=True),
sa.Column('rfc', sa.String(length=13), nullable=True),
sa.Column('tax_id', sa.String(length=50), nullable=True),
# === UBICACIÓN ===
sa.Column('country', sa.String(length=100), nullable=True),
sa.Column('state', sa.String(length=100), nullable=True),
sa.Column('city', sa.String(length=100), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('external_number', sa.String(length=20), nullable=True),
sa.Column('internal_number', sa.String(length=20), nullable=True),
sa.Column('postal_code', sa.String(length=10), nullable=True),
sa.Column('neighborhood', sa.String(length=100), nullable=True),
# === CONTACTO ===
sa.Column('main_phone', sa.String(length=20), nullable=True),
sa.Column('secondary_phone', sa.String(length=20), nullable=True),
sa.Column('direct_phone', sa.String(length=20), nullable=True),
sa.Column('phone_extension', sa.String(length=10), nullable=True),
sa.Column('fax', sa.String(length=20), nullable=True),
# === INFORMACIÓN ADICIONAL ===
sa.Column('business_hours', sa.String(length=255), nullable=True),
sa.Column('website', sa.String(length=255), nullable=True),
sa.Column('main_email', sa.String(length=320), nullable=True),
sa.Column('billing_email', sa.String(length=320), nullable=True),
# === MARKETING ===
sa.Column('advertising_medium', sa.String(length=255), nullable=True),
sa.Column('nationality', sa.String(length=100), nullable=True),
# === CONFIGURACIÓN EMPRESARIAL ===
sa.Column('logo_url', sa.String(length=500), nullable=True),
sa.Column('company_representative', sa.String(length=255), nullable=True),
sa.Column('legal_representative', sa.String(length=255), nullable=True),
# === FINANZAS/FACTURACIÓN ===
sa.Column('credit_limit', sa.Numeric(precision=15, scale=2), nullable=True),
sa.Column('payment_terms', sa.String(length=100), nullable=True),
sa.Column('preferred_currency', sa.String(length=3), nullable=False, default='MXN'),
# === METADATOS ===
sa.Column('send_to_billing', sa.Boolean(), nullable=False, default=False),
sa.Column('is_active_client', sa.Boolean(), nullable=False, default=True),
sa.Column('is_prospect', sa.Boolean(), nullable=False, default=False),
sa.Column('notes', sa.Text(), nullable=True),
# === TIMESTAMPS ===
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, default=sa.func.now(), onupdate=sa.func.now()),
# Constraints
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.UniqueConstraint('tenant_id') # Relación uno a uno con tenant
)
# Crear índices para optimizar consultas
op.create_index('idx_client_profiles_tenant_id', 'client_profiles', ['tenant_id'])
op.create_index('idx_client_profiles_rfc', 'client_profiles', ['rfc'])
op.create_index('idx_client_profiles_business_name', 'client_profiles', ['business_name'])
op.create_index('idx_client_profiles_client_code', 'client_profiles', ['client_code'])
def downgrade() -> None:
# Drop índices
op.drop_index('idx_client_profiles_client_code', table_name='client_profiles')
op.drop_index('idx_client_profiles_business_name', table_name='client_profiles')
op.drop_index('idx_client_profiles_rfc', table_name='client_profiles')
op.drop_index('idx_client_profiles_tenant_id', table_name='client_profiles')
# Drop tabla
op.drop_table('client_profiles')

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation'; import { onMount } from 'svelte';
let currentPassword = ''; let currentPassword = '';
let newPassword = ''; let newPassword = '';
@@ -11,8 +11,52 @@
let lastName = ''; let lastName = '';
let isUpdatingProfile = false; let isUpdatingProfile = false;
let isChangingPassword = false; let isChangingPassword = false;
let isSavingBusinessProfile = false;
let profileErrors: Record<string, string> = {}; let profileErrors: Record<string, string> = {};
let passwordErrors: Record<string, string> = {}; let passwordErrors: Record<string, string> = {};
let businessProfileErrors: Record<string, string> = {};
// Tabs management
let activeTab = 'personal';
// Business profile data
let businessProfile = {
business_name: '',
commercial_name: '',
client_code: '',
client_type: '',
rfc: '',
tax_id: '',
country: 'México',
state: '',
city: '',
address: '',
external_number: '',
internal_number: '',
postal_code: '',
neighborhood: '',
main_phone: '',
secondary_phone: '',
direct_phone: '',
phone_extension: '',
fax: '',
business_hours: '',
website: '',
main_email: '',
billing_email: '',
advertising_medium: '',
nationality: 'Mexicana',
logo_url: '',
company_representative: '',
legal_representative: '',
credit_limit: '',
payment_terms: '',
preferred_currency: 'MXN',
send_to_billing: false,
is_active_client: true,
is_prospect: false,
notes: ''
};
onMount(() => { onMount(() => {
// Redirect if not authenticated // Redirect if not authenticated
@@ -26,8 +70,33 @@
firstName = $auth.user.first_name; firstName = $auth.user.first_name;
lastName = $auth.user.last_name; lastName = $auth.user.last_name;
} }
// Load business profile
loadBusinessProfile();
}); });
async function loadBusinessProfile() {
try {
const response = await fetch('/api/v1/client-profile/', {
headers: {
Authorization: `Bearer ${$auth.token}`
}
});
if (response.ok) {
const profile = await response.json();
// Fill business profile with data
Object.keys(businessProfile).forEach(key => {
if (profile[key] !== undefined && profile[key] !== null) {
businessProfile[key] = profile[key];
}
});
}
} catch (error) {
console.warn('No se pudo cargar el perfil empresarial:', error);
}
}
function validateProfileForm() { function validateProfileForm() {
profileErrors = {}; profileErrors = {};
@@ -64,6 +133,40 @@
return Object.keys(passwordErrors).length === 0; return Object.keys(passwordErrors).length === 0;
} }
function validateBusinessProfile() {
businessProfileErrors = {};
// Validaciones básicas
if (businessProfile.rfc && businessProfile.rfc.length > 0) {
const rfcPattern = /^[A-ZÑ&]{3,4}[0-9]{6}[A-Z0-9]{3}$/;
if (!rfcPattern.test(businessProfile.rfc.toUpperCase())) {
businessProfileErrors.rfc = 'Formato de RFC inválido';
}
}
if (businessProfile.postal_code && businessProfile.postal_code.length > 0) {
if (!/^\d{5}$/.test(businessProfile.postal_code)) {
businessProfileErrors.postal_code = 'Código postal debe tener 5 dígitos';
}
}
if (businessProfile.main_email && businessProfile.main_email.length > 0) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(businessProfile.main_email)) {
businessProfileErrors.main_email = 'Email inválido';
}
}
if (businessProfile.billing_email && businessProfile.billing_email.length > 0) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(businessProfile.billing_email)) {
businessProfileErrors.billing_email = 'Email inválido';
}
}
return Object.keys(businessProfileErrors).length === 0;
}
async function handleProfileUpdate() { async function handleProfileUpdate() {
if (!validateProfileForm()) return; if (!validateProfileForm()) return;
@@ -74,7 +177,7 @@
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${$auth.token}` Authorization: `Bearer ${$auth.token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
first_name: firstName.trim(), first_name: firstName.trim(),
@@ -107,7 +210,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${$auth.token}` Authorization: `Bearer ${$auth.token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
current_password: currentPassword, current_password: currentPassword,
@@ -132,23 +235,129 @@
isChangingPassword = false; isChangingPassword = false;
} }
} }
async function handleBusinessProfileSave() {
if (!validateBusinessProfile()) return;
isSavingBusinessProfile = true;
try {
// Prepare data - remove empty strings and convert types
const profileData = { ...businessProfile };
// Clean up empty strings
Object.keys(profileData).forEach(key => {
if (profileData[key] === '') {
profileData[key] = null;
}
});
// Convert credit_limit to number if provided
if (profileData.credit_limit) {
profileData.credit_limit = parseFloat(profileData.credit_limit);
}
const response = await fetch('/api/v1/client-profile/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$auth.token}`
},
body: JSON.stringify(profileData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Error al guardar perfil empresarial');
}
const savedProfile = await response.json();
toast.success('Perfil empresarial guardado exitosamente');
// Update local data
Object.keys(businessProfile).forEach(key => {
if (savedProfile[key] !== undefined && savedProfile[key] !== null) {
businessProfile[key] = savedProfile[key];
}
});
} catch (error: any) {
toast.error(error.message || 'Error al guardar perfil empresarial');
} finally {
isSavingBusinessProfile = false;
}
}
</script> </script>
<svelte:head> <svelte:head>
<title>Mi Perfil - ServiceManager</title> <title>Mi Perfil - ServiceManager</title>
</svelte:head> </svelte:head>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Mi Perfil</h1> <h1 class="text-3xl font-bold text-gray-900">Mi Perfil</h1>
<p class="text-gray-600 mt-2"> <p class="text-gray-600 mt-2">Gestiona tu información personal y configuración empresarial</p>
Gestiona tu información personal y configuración de seguridad
</p>
</div> </div>
<!-- Tabs Navigation -->
<div class="border-b border-gray-200 mb-8">
<nav class="-mb-px flex space-x-8">
<button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'personal'
? 'border-primary-500 text-primary-600'
: ''}"
on:click={() => (activeTab = 'personal')}
>
👤 Personal
</button>
<button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'general'
? 'border-primary-500 text-primary-600'
: ''}"
on:click={() => (activeTab = 'general')}
>
🏢 General
</button>
<button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'contact'
? 'border-primary-500 text-primary-600'
: ''}"
on:click={() => (activeTab = 'contact')}
>
📞 Contacto
</button>
<button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'security'
? 'border-primary-500 text-primary-600'
: ''}"
on:click={() => (activeTab = 'security')}
>
🔐 Seguridad
</button>
<button
class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors {activeTab ===
'account'
? 'border-primary-500 text-primary-600'
: ''}"
on:click={() => (activeTab = 'account')}
>
Cuenta
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="space-y-8"> <div class="space-y-8">
<!-- Profile Information --> <!-- Personal Information Tab -->
{#if activeTab === 'personal'}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Información Personal</h2> <h2 class="text-xl font-semibold text-gray-900">Información Personal</h2>
@@ -199,21 +408,19 @@
class="form-input bg-gray-50" class="form-input bg-gray-50"
value={$auth.user?.email || ''} value={$auth.user?.email || ''}
disabled disabled
readonly
/> />
<p class="text-xs text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
El correo electrónico no se puede cambiar. Contacta con soporte si necesitas actualizarlo. El correo electrónico no puede ser modificado. Contacta al administrador si
necesitas cambiarlo.
</p> </p>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<button <button type="submit" class="btn-primary px-6 py-2" disabled={isUpdatingProfile}>
type="submit"
class="btn-primary px-6 py-2"
disabled={isUpdatingProfile}
>
{#if isUpdatingProfile} {#if isUpdatingProfile}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div> <div class="spinner w-4 h-4" />
<span>Guardando...</span> <span>Guardando...</span>
</div> </div>
{:else} {:else}
@@ -224,11 +431,434 @@
</form> </form>
</div> </div>
</div> </div>
{/if}
<!-- Account Security --> <!-- General Business Information Tab -->
{#if activeTab === 'general'}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Seguridad de la Cuenta</h2> <h2 class="text-xl font-semibold text-gray-900">Información Empresarial</h2>
<p class="text-gray-600 mt-1">Datos generales de tu empresa</p>
</div>
<div class="card-content">
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
<!-- Información General -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="font-medium text-gray-900 mb-4">Información General</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="form-label">Razón Social</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.business_name}
placeholder="Empresa S.A. de C.V."
/>
</div>
<div>
<label class="form-label">Nombre Comercial</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.commercial_name}
placeholder="Mi Empresa"
/>
</div>
<div>
<label class="form-label">Clave de Cliente</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.client_code}
placeholder="CLI001"
/>
</div>
<div>
<label class="form-label">Tipo de Cliente</label>
<select class="form-input" bind:value={businessProfile.client_type}>
<option value="">Seleccionar...</option>
<option value="corporativo">Corporativo</option>
<option value="pyme">PyME</option>
<option value="startup">Startup</option>
<option value="gobierno">Gobierno</option>
<option value="ong">ONG</option>
</select>
</div>
<div>
<label class="form-label">RFC</label>
<input
type="text"
class="form-input {businessProfileErrors.rfc ? 'border-red-300' : ''}"
bind:value={businessProfile.rfc}
placeholder="XAXX010101000"
maxlength="13"
style="text-transform: uppercase"
/>
{#if businessProfileErrors.rfc}
<p class="form-error">{businessProfileErrors.rfc}</p>
{/if}
</div>
<div>
<label class="form-label">ID Fiscal (Otros países)</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.tax_id}
placeholder="Tax ID / VAT Number"
/>
</div>
</div>
</div>
<!-- Ubicación -->
<div class="bg-blue-50 p-4 rounded-lg">
<h3 class="font-medium text-gray-900 mb-4">Ubicación</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<label class="form-label">País</label>
<input type="text" class="form-input" bind:value={businessProfile.country} />
</div>
<div>
<label class="form-label">Estado</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.state}
placeholder="Chihuahua"
/>
</div>
<div>
<label class="form-label">Ciudad</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.city}
placeholder="Ciudad Juárez"
/>
</div>
<div class="md:col-span-2">
<label class="form-label">Dirección</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.address}
placeholder="Av. Principal 123"
/>
</div>
<div>
<label class="form-label">Número Exterior</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.external_number}
placeholder="123"
/>
</div>
<div>
<label class="form-label">Número Interior</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.internal_number}
placeholder="A"
/>
</div>
<div>
<label class="form-label">Código Postal</label>
<input
type="text"
class="form-input {businessProfileErrors.postal_code ? 'border-red-300' : ''}"
bind:value={businessProfile.postal_code}
placeholder="32000"
maxlength="5"
/>
{#if businessProfileErrors.postal_code}
<p class="form-error">{businessProfileErrors.postal_code}</p>
{/if}
</div>
<div>
<label class="form-label">Colonia</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.neighborhood}
placeholder="Centro"
/>
</div>
</div>
</div>
<!-- Representantes -->
<div class="bg-purple-50 p-4 rounded-lg">
<h3 class="font-medium text-gray-900 mb-4">Representantes</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="form-label">Encargado/Representante</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.company_representative}
placeholder="Juan Pérez"
/>
</div>
<div>
<label class="form-label">Representante Legal</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.legal_representative}
placeholder="María González"
/>
</div>
</div>
</div>
<!-- Configuración -->
<div class="bg-green-50 p-4 rounded-lg">
<h3 class="font-medium text-gray-900 mb-4">Configuración</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="form-label">Moneda Preferida</label>
<select class="form-input" bind:value={businessProfile.preferred_currency}>
<option value="MXN">MXN - Peso Mexicano</option>
<option value="USD">USD - Dólar Americano</option>
<option value="EUR">EUR - Euro</option>
</select>
</div>
<div>
<label class="form-label">Nacionalidad</label>
<input type="text" class="form-input" bind:value={businessProfile.nationality} />
</div>
<div class="md:col-span-2">
<label class="form-label">Notas Adicionales</label>
<textarea
class="form-input"
rows="3"
bind:value={businessProfile.notes}
placeholder="Información adicional sobre la empresa..."
/>
</div>
</div>
<div class="mt-4 space-y-3">
<label class="flex items-center space-x-2">
<input
type="checkbox"
class="form-checkbox"
bind:checked={businessProfile.send_to_billing}
/>
<span class="text-sm text-gray-700">Enviar a Facturación</span>
</label>
<label class="flex items-center space-x-2">
<input
type="checkbox"
class="form-checkbox"
bind:checked={businessProfile.is_prospect}
/>
<span class="text-sm text-gray-700">Es Prospecto</span>
</label>
</div>
</div>
<div class="flex justify-end pt-6">
<button
type="submit"
class="btn-primary px-8 py-2"
disabled={isSavingBusinessProfile}
>
{#if isSavingBusinessProfile}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" />
<span>Guardando...</span>
</div>
{:else}
💾 Guardar Perfil Empresarial
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Contact Information Tab -->
{#if activeTab === 'contact'}
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Información de Contacto</h2>
<p class="text-gray-600 mt-1">Datos de contacto y comunicación</p>
</div>
<div class="card-content">
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
<!-- Teléfonos -->
<div class="bg-blue-50 p-4 rounded-lg">
<h3 class="font-medium text-gray-900 mb-4">📞 Teléfonos</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="form-label">Teléfono Principal</label>
<input
type="tel"
class="form-input"
bind:value={businessProfile.main_phone}
placeholder="+52 656 123 4567"
/>
</div>
<div>
<label class="form-label">Teléfono Secundario</label>
<input
type="tel"
class="form-input"
bind:value={businessProfile.secondary_phone}
placeholder="+52 656 123 4568"
/>
</div>
<div>
<label class="form-label">Teléfono Directo</label>
<input
type="tel"
class="form-input"
bind:value={businessProfile.direct_phone}
placeholder="+52 656 123 4569"
/>
</div>
<div>
<label class="form-label">Extensión</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.phone_extension}
placeholder="101"
maxlength="10"
/>
</div>
<div>
<label class="form-label">Fax</label>
<input
type="tel"
class="form-input"
bind:value={businessProfile.fax}
placeholder="+52 656 123 4570"
/>
</div>
</div>
</div>
<!-- Emails -->
<div class="bg-green-50 p-4 rounded-lg">
<h3 class="font-medium text-gray-900 mb-4">✉️ Correos Electrónicos</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="form-label">Email Principal</label>
<input
type="email"
class="form-input {businessProfileErrors.main_email ? 'border-red-300' : ''}"
bind:value={businessProfile.main_email}
placeholder="contacto@empresa.com"
/>
{#if businessProfileErrors.main_email}
<p class="form-error">{businessProfileErrors.main_email}</p>
{/if}
</div>
<div>
<label class="form-label">Email de Facturación</label>
<input
type="email"
class="form-input {businessProfileErrors.billing_email ? 'border-red-300' : ''}"
bind:value={businessProfile.billing_email}
placeholder="facturacion@empresa.com"
/>
{#if businessProfileErrors.billing_email}
<p class="form-error">{businessProfileErrors.billing_email}</p>
{/if}
</div>
</div>
</div>
<!-- Web y Horarios -->
<div class="bg-purple-50 p-4 rounded-lg">
<h3 class="font-medium text-gray-900 mb-4">🌐 Web y Horarios</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="form-label">Página Web</label>
<input
type="url"
class="form-input"
bind:value={businessProfile.website}
placeholder="https://www.empresa.com"
/>
</div>
<div>
<label class="form-label">Horario de Atención</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.business_hours}
placeholder="Lun-Vie 9:00-18:00"
/>
</div>
<div class="md:col-span-2">
<label class="form-label">Medio de Publicidad</label>
<input
type="text"
class="form-input"
bind:value={businessProfile.advertising_medium}
placeholder="¿Cómo nos conoció?"
/>
</div>
</div>
</div>
<div class="flex justify-end pt-6">
<button
type="submit"
class="btn-primary px-8 py-2"
disabled={isSavingBusinessProfile}
>
{#if isSavingBusinessProfile}
<div class="flex items-center space-x-2">
<div class="spinner w-4 h-4" />
<span>Guardando...</span>
</div>
{:else}
💾 Guardar Información de Contacto
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Security Tab -->
{#if activeTab === 'security'}
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Configuración de Seguridad</h2>
<p class="text-gray-600 mt-1">Gestiona tu contraseña y configuración de seguridad</p> <p class="text-gray-600 mt-1">Gestiona tu contraseña y configuración de seguridad</p>
</div> </div>
@@ -245,16 +875,30 @@
</div> </div>
<div> <div>
{#if $auth.user?.is_two_factor_enabled} {#if $auth.user?.is_two_factor_enabled}
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800"> <span
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg> </svg>
Habilitado Habilitado
</span> </span>
{:else} {:else}
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800"> <span
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
Deshabilitado Deshabilitado
</span> </span>
@@ -282,7 +926,6 @@
{/if} {/if}
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label for="new-password" class="form-label"> <label for="new-password" class="form-label">
Nueva Contraseña <span class="text-red-500">*</span> Nueva Contraseña <span class="text-red-500">*</span>
@@ -297,9 +940,6 @@
{#if passwordErrors.newPassword} {#if passwordErrors.newPassword}
<p class="form-error">{passwordErrors.newPassword}</p> <p class="form-error">{passwordErrors.newPassword}</p>
{/if} {/if}
<p class="text-xs text-gray-500 mt-1">
Mínimo 8 caracteres
</p>
</div> </div>
<div> <div>
@@ -317,17 +957,12 @@
<p class="form-error">{passwordErrors.confirmPassword}</p> <p class="form-error">{passwordErrors.confirmPassword}</p>
{/if} {/if}
</div> </div>
</div>
<div class="flex justify-end"> <div class="flex justify-end">
<button <button type="submit" class="btn-primary px-6 py-2" disabled={isChangingPassword}>
type="submit"
class="btn-primary px-6 py-2"
disabled={isChangingPassword}
>
{#if isChangingPassword} {#if isChangingPassword}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="spinner w-4 h-4"></div> <div class="spinner w-4 h-4" />
<span>Cambiando...</span> <span>Cambiando...</span>
</div> </div>
{:else} {:else}
@@ -338,8 +973,10 @@
</form> </form>
</div> </div>
</div> </div>
{/if}
<!-- Account Information --> <!-- Account Information Tab -->
{#if activeTab === 'account'}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Información de la Cuenta</h2> <h2 class="text-xl font-semibold text-gray-900">Información de la Cuenta</h2>
@@ -350,13 +987,17 @@
<dl class="grid grid-cols-1 md:grid-cols-2 gap-6"> <dl class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<dt class="text-sm font-medium text-gray-500">ID de Usuario</dt> <dt class="text-sm font-medium text-gray-500">ID de Usuario</dt>
<dd class="text-sm text-gray-900 font-mono mt-1">#{$auth.user?.id.substring(0, 8)}</dd> <dd class="text-sm text-gray-900 font-mono mt-1">
#{$auth.user?.id.substring(0, 8)}
</dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Rol</dt> <dt class="text-sm font-medium text-gray-500">Rol</dt>
<dd class="text-sm text-gray-900 mt-1"> <dd class="text-sm text-gray-900 mt-1">
{$auth.user?.role === 'CLIENT_ADMIN' ? 'Administrador de Cliente' : 'Usuario de Cliente'} {$auth.user?.role === 'CLIENT_ADMIN'
? 'Administrador de Cliente'
: 'Usuario de Cliente'}
</dd> </dd>
</div> </div>
@@ -364,11 +1005,15 @@
<dt class="text-sm font-medium text-gray-500">Estado de la Cuenta</dt> <dt class="text-sm font-medium text-gray-500">Estado de la Cuenta</dt>
<dd class="text-sm mt-1"> <dd class="text-sm mt-1">
{#if $auth.user?.is_active} {#if $auth.user?.is_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"> <span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
Activa Activa
</span> </span>
{:else} {:else}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"> <span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"
>
Inactiva Inactiva
</span> </span>
{/if} {/if}
@@ -378,15 +1023,47 @@
<div> <div>
<dt class="text-sm font-medium text-gray-500">Miembro desde</dt> <dt class="text-sm font-medium text-gray-500">Miembro desde</dt>
<dd class="text-sm text-gray-900 mt-1"> <dd class="text-sm text-gray-900 mt-1">
{$auth.user?.created_at ? new Date($auth.user.created_at).toLocaleDateString('es-ES', { {$auth.user?.created_at
? new Date($auth.user.created_at).toLocaleDateString('es-ES', {
day: '2-digit', day: '2-digit',
month: 'long', month: 'long',
year: 'numeric' year: 'numeric'
}) : 'N/A'} })
: 'N/A'}
</dd> </dd>
</div> </div>
</dl> </dl>
</div> </div>
</div> </div>
{/if}
</div> </div>
</div> </div>
<style>
.spinner {
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.form-checkbox {
border-radius: 0.25rem;
border-color: #d1d5db;
color: #2563eb;
}
.form-checkbox:focus {
ring-color: #3b82f6;
border-color: #3b82f6;
}
</style>

File diff suppressed because it is too large Load Diff