diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 742d254..b83feb4 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -9,6 +9,7 @@ from app.core.database import get_db from app.core.security import security from app.core.config import get_settings from app.models.user import User, UserRole +from app.models.tenant import Tenant settings = get_settings() @@ -55,3 +56,20 @@ async def get_current_active_superuser( status_code=403, detail="The user doesn't have enough privileges" ) 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 diff --git a/backend/app/api/schemas/__init__.py b/backend/app/api/schemas/__init__.py new file mode 100644 index 0000000..7d62992 --- /dev/null +++ b/backend/app/api/schemas/__init__.py @@ -0,0 +1,15 @@ +"""Schemas package initialization.""" + +from .client_profile import ( + ClientProfileCreate, + ClientProfileUpdate, + ClientProfileResponse, + ClientProfileSummary +) + +__all__ = [ + "ClientProfileCreate", + "ClientProfileUpdate", + "ClientProfileResponse", + "ClientProfileSummary" +] \ No newline at end of file diff --git a/backend/app/api/schemas/client_profile.py b/backend/app/api/schemas/client_profile.py new file mode 100644 index 0000000..c576e02 --- /dev/null +++ b/backend/app/api/schemas/client_profile.py @@ -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" \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/client_profile.py b/backend/app/api/v1/endpoints/client_profile.py new file mode 100644 index 0000000..2fbcc80 --- /dev/null +++ b/backend/app/api/v1/endpoints/client_profile.py @@ -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 \ No newline at end of file diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 65174f1..5edd7fa 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -6,7 +6,7 @@ Router principal para la API v1 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() @@ -54,6 +54,9 @@ api_router.include_router( tags=["tickets"] ) -# Ensure FastAPI is installed in the environment -# If not, install it using: -# pip install fastapi \ No newline at end of file +# Client Profile routes +api_router.include_router( + client_profile.router, + prefix="/client-profile", + tags=["client-profile"] +) \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..aba036e --- /dev/null +++ b/backend/app/models/__init__.py @@ -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" +] \ No newline at end of file diff --git a/backend/app/models/client_profile.py b/backend/app/models/client_profile.py new file mode 100644 index 0000000..c1407a2 --- /dev/null +++ b/backend/app/models/client_profile.py @@ -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"" + + @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" \ No newline at end of file diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index 35782b4..bee1f70 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -54,6 +54,7 @@ class Tenant(Base): users: Mapped[List["User"]] = relationship("User", back_populates="tenant") tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant") 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: return f"" diff --git a/backend/migrations/versions/13362e8c493a_add_client_profiles_table.py b/backend/migrations/versions/13362e8c493a_add_client_profiles_table.py new file mode 100644 index 0000000..16169c9 --- /dev/null +++ b/backend/migrations/versions/13362e8c493a_add_client_profiles_table.py @@ -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') \ No newline at end of file diff --git a/frontend-client/src/routes/profile/+page.svelte b/frontend-client/src/routes/profile/+page.svelte index 04024e0..bf614be 100644 --- a/frontend-client/src/routes/profile/+page.svelte +++ b/frontend-client/src/routes/profile/+page.svelte @@ -1,9 +1,9 @@ Mi Perfil - ServiceManager -
+

Mi Perfil

-

- Gestiona tu información personal y configuración de seguridad -

+

Gestiona tu información personal y configuración empresarial

- + + +
+ +
+ +
- -
-
-

Información Personal

-

Actualiza tu información básica

-
- -
-
-
-
- - - {#if profileErrors.firstName} -

{profileErrors.firstName}

- {/if} -
- -
- - - {#if profileErrors.lastName} -

{profileErrors.lastName}

- {/if} -
-
- -
- - -

- El correo electrónico no se puede cambiar. Contacta con soporte si necesitas actualizarlo. -

-
- -
- -
-
-
-
- - -
-
-

Seguridad de la Cuenta

-

Gestiona tu contraseña y configuración de seguridad

-
- -
- -
-
-

Autenticación de dos factores (2FA)

-

- {$auth.user?.is_two_factor_enabled - ? 'La autenticación de dos factores está habilitada' - : 'Mejora la seguridad habilitando 2FA'} -

-
-
- {#if $auth.user?.is_two_factor_enabled} - - - - - Habilitado - - {:else} - - - - - Deshabilitado - - {/if} -
+ + {#if activeTab === 'personal'} +
+
+

Información Personal

+

Actualiza tu información básica

- - -
-

Cambiar Contraseña

- -
- - - {#if passwordErrors.currentPassword} -

{passwordErrors.currentPassword}

- {/if} + +
+ +
+
+ + + {#if profileErrors.firstName} +

{profileErrors.firstName}

+ {/if} +
+ +
+ + + {#if profileErrors.lastName} +

{profileErrors.lastName}

+ {/if} +
+
+ +
+ + +

+ El correo electrónico no puede ser modificado. Contacta al administrador si + necesitas cambiarlo. +

+
+ +
+ +
+ +
+
+ {/if} + + + {#if activeTab === 'general'} +
+
+

Información Empresarial

+

Datos generales de tu empresa

+
+ +
+
+ +
+

Información General

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {#if businessProfileErrors.rfc} +

{businessProfileErrors.rfc}

+ {/if} +
+ +
+ + +
+
+
+ + +
+

Ubicación

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {#if businessProfileErrors.postal_code} +

{businessProfileErrors.postal_code}

+ {/if} +
+ +
+ + +
+
+
+ + +
+

Representantes

+
+
+ + +
+ +
+ + +
+
+
+ + +
+

Configuración

+
+
+ + +
+ +
+ + +
+ +
+ +