feat(company): add logo upload functionality and update logo field size
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -60,4 +60,4 @@ node_modules/
|
||||
# Docker
|
||||
*.dockerignore
|
||||
postgres-data/
|
||||
backend/uploads/avatars
|
||||
backend/uploads/
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user