feat(company): add logo upload functionality and update logo field size

This commit is contained in:
2026-01-13 13:06:14 -06:00
parent c00107d921
commit 0a1b6cd1e0
6 changed files with 317 additions and 41 deletions

2
.gitignore vendored
View File

@@ -60,4 +60,4 @@ node_modules/
# Docker
*.dockerignore
postgres-data/
backend/uploads/avatars
backend/uploads/

View File

@@ -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)

View File

@@ -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
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,
}

View File

@@ -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<Ap
export async function deleteCompany(id: number): Promise<ApiResponse<void>> {
return await api.delete(`/v1/a76/company/${id}`);
}
export async function uploadCompanyLogo(id: number, file: File): Promise<ApiResponse<{ message: string; logo_path: string; company_id: number }>> {
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
};
}

View File

@@ -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
);
</script>
@@ -22,19 +30,20 @@
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg overflow-hidden"
>
{#if companyStore.activeCompany?.logo}
{#if activeCompanyLogoUrl}
<img
src={companyStore.activeCompany.logo}
alt={companyStore.activeCompany.name}
class="size-full rounded-lg object-cover"
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || 'Company'}
class="size-full object-cover"
onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
{:else}
<BuildingIcon class="size-4 text-white" />
{/if}
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<div class="grid flex-1 text-left text-sm leading-tight min-w-0">
<span class="truncate font-medium">
{companyStore.activeCompany?.name || 'Seleccionar compañía'}
</span>
@@ -72,10 +81,10 @@
onSelect={() => companyStore.setActiveCompany(company)}
class="gap-2 p-2 cursor-pointer"
>
<div class="flex size-6 items-center justify-center rounded-md border">
<div class="flex size-6 items-center justify-center rounded-md border overflow-hidden">
{#if company.logo}
<img
src={company.logo}
src={getBackendAssetUrl(company.logo)}
alt={company.name}
class="size-full rounded object-cover"
/>
@@ -83,10 +92,10 @@
<BuildingIcon class="size-3.5 shrink-0" />
{/if}
</div>
<div class="flex flex-1 flex-col">
<span class="font-medium">{company.name}</span>
<div class="flex flex-1 flex-col min-w-0">
<span class="font-medium truncate">{company.name}</span>
{#if company.rfc}
<span class="text-xs text-muted-foreground">{company.rfc}</span>
<span class="text-xs text-muted-foreground truncate">{company.rfc}</span>
{/if}
</div>
{#if companyStore.activeCompany?.id === company.id}

View File

@@ -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<string | null>(null);
let logoFile = $state<File | null>(null);
let logoPreview = $state<string | null>(null);
let currentLogo = $state<string | null>(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 @@
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6 pb-48">
<Card.Root>
<Card.Content class="p-6">
<Tabs.Root value="general" class="w-full">
<Tabs.Root bind:value={activeTab} class="w-full">
<div class="min-h-[400px]">
<Tabs.Content value="general" class="space-y-4 pt-4">
<!-- Logo Upload Section -->
<div class="grid gap-4 p-4 border rounded-lg bg-muted/30">
<Label>Logo de la Empresa</Label>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
{#if logoPreview || currentLogoUrl}
<div class="relative w-32 h-32 border-2 border-dashed rounded-lg overflow-hidden">
<img
src={logoPreview || currentLogoUrl}
alt="Logo preview"
class="w-full h-full object-contain"
/>
{#if logoPreview}
<button
type="button"
onclick={removeLogo}
class="absolute top-1 right-1 p-1 bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90"
>
<X class="h-3 w-3" />
</button>
{/if}
</div>
{:else}
<div class="w-32 h-32 border-2 border-dashed rounded-lg flex items-center justify-center bg-muted">
<Upload class="h-8 w-8 text-muted-foreground" />
</div>
{/if}
<div class="flex-1 space-y-2">
<Input
id="logo-input"
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onchange={handleLogoChange}
class="cursor-pointer"
/>
<p class="text-xs text-muted-foreground">
Formatos permitidos: JPG, PNG, GIF, WEBP. Tamaño máximo: 5MB
</p>
</div>
</div>
</div>
<div class="grid gap-2">
<Label for="name">Razón Social <span class="text-destructive">*</span></Label>
<Input id="name" bind:value={formData.name} placeholder="Nombre oficial" />
@@ -264,31 +386,53 @@
</div>
</Tabs.Content>
</div>
<Tabs.List class="grid grid-cols-4 fixed bottom-24 left-1/2 -translate-x-1/2 w-[95%] max-w-2xl z-40 shadow-2xl bg-background border p-1 rounded-xl">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="programas">Programas</Tabs.Trigger>
<Tabs.Trigger value="responsable">Responsable</Tabs.Trigger>
<Tabs.Trigger value="config">Config</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</Card.Content>
</Card.Root>
</form>
<div class="fixed bottom-0 right-0 left-0 md:left-64 p-4 border-t bg-background/95 backdrop-blur z-50">
<div class="max-w-6xl mx-auto flex justify-end gap-4">
<Button type="button" variant="ghost" onclick={() => goto('/dashboard/general_catalogs/company_information')} disabled={loading}>
<!-- Footer fijo en la parte inferior -->
<div class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-50 group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]">
<div class="px-4 py-4 space-y-4 max-w-6xl mx-auto">
<!-- Tabs Navigation -->
<Tabs.Root bind:value={activeTab} class="w-full">
<div class="w-full overflow-x-auto pb-2">
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-4">
<Tabs.Trigger value="general" class="whitespace-nowrap">
<Building2 class="mr-2 h-4 w-4" />
General
</Tabs.Trigger>
<Tabs.Trigger value="programas" class="whitespace-nowrap">
<FileText class="mr-2 h-4 w-4" />
Programas
</Tabs.Trigger>
<Tabs.Trigger value="responsable" class="whitespace-nowrap">
<User class="mr-2 h-4 w-4" />
Responsable
</Tabs.Trigger>
<Tabs.Trigger value="config" class="whitespace-nowrap">
<Settings class="mr-2 h-4 w-4" />
Config
</Tabs.Trigger>
</Tabs.List>
</div>
</Tabs.Root>
<!-- Botones de acción -->
<div class="flex justify-end gap-3">
<Button type="button" variant="outline" onclick={() => goto('/dashboard/general_catalogs/company_information')} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading} class="min-w-[140px]">
<Button type="submit" disabled={loading} onclick={handleSubmit}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{isEdit ? 'Actualizando...' : 'Guardando...'}
{:else}
<Save class="mr-2 h-4 w-4" />
{isEdit ? 'Actualizar' : 'Guardar'}
{/if}
{isEdit ? 'Actualizar' : 'Guardar'}
</Button>
</div>
</div>
</form>
</div>
</div>