diff --git a/.gitignore b/.gitignore index 911dde10..9beb6fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,4 @@ node_modules/ # Docker *.dockerignore postgres-data/ -backend/uploads/avatars \ No newline at end of file +backend/uploads/ \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py index b291dbb5..18bdd840 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -57,7 +57,7 @@ class Company(Base, TimestampMixin): position: Mapped[Optional[str]] = mapped_column(String(30)) # Configuración - logo: Mapped[Optional[str]] = mapped_column(String(255)) + logo: Mapped[Optional[str]] = mapped_column(String(500)) has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean) order_format_type: Mapped[Optional[str]] = mapped_column(String(19)) previous_code: Mapped[Optional[int]] = mapped_column(SmallInteger) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 294ff490..d62fc99a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -2,9 +2,12 @@ Rutas para gestión de empresa """ +import os +import shutil from typing import List, Optional +from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from sqlalchemy.orm import Session from core.database import get_core_db @@ -14,9 +17,15 @@ from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO from .models import Company from .service import CompanyService +# Configuración de directorios +UPLOAD_DIR = "uploads/companies" +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB + # Main router that includes base CRUD router = APIRouter(prefix="/company") + @router.post( "", # Se suma al prefix, queda POST /api/v1/a76/company response_model=CompanyResponseDTO, @@ -28,7 +37,7 @@ async def create_company( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException( @@ -70,12 +79,12 @@ async def list_companies( service = CompanyService(db) items, total = service.get_all( - db, - tenant_id, + db, + tenant_id, company_id=0, # Not used for companies - skip=skip, + skip=skip, limit=page_size, - filters=filters if filters else None + filters=filters if filters else None, ) total_pages = (total + page_size - 1) // page_size @@ -286,9 +295,7 @@ async def update_company( detail="Tenant ID not found in user data", ) - updated_company = CompanyService.update( - db, company_id, tenant_id, 0, data - ) + updated_company = CompanyService.update(db, company_id, tenant_id, 0, data) if not updated_company: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -323,4 +330,86 @@ async def delete_company( detail="Company not found", ) - return None \ No newline at end of file + return None + + +@router.post( + "/{company_id}/upload-logo", + response_model=dict, + summary="Upload company logo", +) +async def upload_company_logo( + company_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """Upload a logo for a company""" + tenant_id = current_user.get("tenant_id") + if not tenant_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tenant ID not found in user data", + ) + + # Validar que la empresa existe + company = CompanyService.get_by_id(db, company_id, tenant_id, 0) + if not company: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Company not found", + ) + + # Validar extensión + file_ext = os.path.splitext(file.filename)[1].lower() + if file_ext not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}", + ) + + # Validar tamaño + content = await file.read() + if len(content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File too large. Maximum size: {MAX_FILE_SIZE / 1024 / 1024}MB", + ) + + # Crear directorio si no existe + os.makedirs(UPLOAD_DIR, exist_ok=True) + + # Eliminar logo anterior si existe + if company.logo: + old_logo_path = company.logo + if os.path.exists(old_logo_path): + try: + os.remove(old_logo_path) + except Exception: + pass # No es crítico si falla + + # Generar nombre único + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"company_{company_id}_{timestamp}{file_ext}" + file_path = os.path.join(UPLOAD_DIR, filename) + + # Guardar archivo + try: + await file.seek(0) + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error saving file: {str(e)}", + ) + + # Actualizar la empresa con la ruta del logo + update_data = CompanyUpdateDTO(logo=file_path) + updated_company = CompanyService.update(db, company_id, tenant_id, 0, update_data) + + return { + "message": "Logo uploaded successfully", + "logo_path": file_path, + "company_id": company_id, + } diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index d31e6aba..fff914d5 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -20,6 +20,7 @@ export interface Company { responsible_mother_last_name: string | null; responsible_rfc?: string | null; position?: string | null; + logo?: string | null; has_express_line?: boolean; is_service_company?: boolean; order_format_type?: string | null; @@ -113,3 +114,36 @@ export async function updateCompany(id: number, data: CompanyUpdate): Promise> { return await api.delete(`/v1/a76/company/${id}`); } + +export async function uploadCompanyLogo(id: number, file: File): Promise> { + const formData = new FormData(); + formData.append('file', file); + + // Para FormData, usamos fetch directamente ya que necesitamos omitir Content-Type + // para que el navegador establezca el boundary automáticamente + const token = localStorage.getItem('access_token'); + const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); + + const response = await fetch(`${API_BASE_URL}/v1/a76/company/${id}/upload-logo`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + credentials: 'include' + }); + + const data = await response.json(); + + if (!response.ok) { + return { + error: data.detail || data.message || 'Error al subir el logo', + status: response.status + }; + } + + return { + data, + status: response.status + }; +} diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index c57880c1..341a0142 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -6,8 +6,16 @@ import BuildingIcon from "@lucide/svelte/icons/building"; import CheckIcon from "@lucide/svelte/icons/check"; import { companyStore } from "$lib/stores/company.svelte"; + import { getBackendAssetUrl } from "$lib/utils"; const sidebar = useSidebar(); + + // Derivar la URL del logo + let activeCompanyLogoUrl = $derived( + companyStore.activeCompany?.logo + ? getBackendAssetUrl(companyStore.activeCompany.logo) + : null + ); @@ -22,19 +30,20 @@ class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" >
- {#if companyStore.activeCompany?.logo} + {#if activeCompanyLogoUrl} {companyStore.activeCompany.name} { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> {:else} {/if}
-
+
{companyStore.activeCompany?.name || 'Seleccionar compañía'} @@ -72,10 +81,10 @@ onSelect={() => companyStore.setActiveCompany(company)} class="gap-2 p-2 cursor-pointer" > -
+
{#if company.logo} {company.name} @@ -83,10 +92,10 @@ {/if}
-
- {company.name} +
+ {company.name} {#if company.rfc} - {company.rfc} + {company.rfc} {/if}
{#if companyStore.activeCompany?.id === company.id} diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte index 36d0cfa7..79454863 100644 --- a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte @@ -10,10 +10,12 @@ import { createCompany, updateCompany, - getCompany, // Asumiendo que esta función existe en tu API + getCompany, + uploadCompanyLogo, type Company } from '$lib/api/dashboard/a76/general_catalogs/company'; - import { ArrowLeft, LoaderCircle, Save } from 'lucide-svelte'; + import { getBackendAssetUrl } from '$lib/utils'; + import { ArrowLeft, LoaderCircle, Save, Upload, X, Building2, FileText, User, Settings } from 'lucide-svelte'; // 1. Lógica de Navegación y Modo const id = $derived($page.params.id); @@ -22,6 +24,16 @@ let loading = $state(false); let error = $state(null); + let logoFile = $state(null); + let logoPreview = $state(null); + let currentLogo = $state(null); + let uploadingLogo = $state(false); + let activeTab = $state('general'); + + // URL completa del logo derivada + let currentLogoUrl = $derived( + logoPreview || getBackendAssetUrl(currentLogo) || '' + ); // 2. Estado Inicial (Reset) const initialData = { @@ -51,7 +63,7 @@ // 3. Efecto para "Heredar" datos o Limpiar $effect(() => { if (isEdit) { - fetchData(id); + fetchData(String(id)); } else { formData = { ...initialData }; error = null; @@ -86,6 +98,10 @@ ctpat_svi: item.ctpat_svi || '', trusted_exporter_number: item.trusted_exporter_number || '' }; + // Guardar la URL del logo actual si existe + if (item.logo) { + currentLogo = item.logo; + } } } catch (e) { error = "Error al cargar los datos de la empresa"; @@ -94,6 +110,66 @@ } } + function handleLogoChange(event: Event) { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (file) { + // Validar tipo + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + error = 'Tipo de archivo no permitido. Solo JPG, PNG, GIF o WEBP'; + return; + } + + // Validar tamaño (5MB) + if (file.size > 5 * 1024 * 1024) { + error = 'El archivo es demasiado grande. Máximo 5MB'; + return; + } + + logoFile = file; + + // Crear preview + const reader = new FileReader(); + reader.onload = (e) => { + logoPreview = e.target?.result as string; + }; + reader.readAsDataURL(file); + error = null; + } + } + + function removeLogo() { + logoFile = null; + logoPreview = null; + // Resetear el input + const input = document.getElementById('logo-input') as HTMLInputElement; + if (input) input.value = ''; + } + + async function uploadLogo(companyId: number) { + if (!logoFile) return; + + uploadingLogo = true; + try { + const response = await uploadCompanyLogo(companyId, logoFile); + if (response.error) { + throw new Error(response.error); + } + // Actualizar la ruta del logo actual + if (response.data?.logo_path) { + currentLogo = response.data.logo_path; + logoFile = null; + logoPreview = null; + } + } catch (e: any) { + error = `Error al subir el logo: ${e.message}`; + } finally { + uploadingLogo = false; + } + } + const clean = (value: any) => (typeof value === 'string' ? value.trim() || null : value); async function handleSubmit() { @@ -131,6 +207,11 @@ if (response.error) throw new Error(response.error); + // Si hay un logo para subir y ya tenemos la empresa creada/actualizada + if (logoFile && response.data?.id) { + await uploadLogo(response.data.id); + } + goto('/dashboard/general_catalogs/company_information'); } catch (e: any) { error = e.message || 'Error al guardar'; @@ -157,12 +238,53 @@
{/if} -
{ e.preventDefault(); handleSubmit(); }} class="space-y-6"> + { e.preventDefault(); handleSubmit(); }} class="space-y-6 pb-48"> - +
+ +
+ +
+ {#if logoPreview || currentLogoUrl} +
+ Logo preview + {#if logoPreview} + + {/if} +
+ {:else} +
+ +
+ {/if} +
+ +

+ Formatos permitidos: JPG, PNG, GIF, WEBP. Tamaño máximo: 5MB +

+
+
+
+
@@ -264,31 +386,53 @@
- - - General - Programas - Responsable - Config -
+
-
-
- -
- +
\ No newline at end of file