diff --git a/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py b/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py index 0e228d57..8ff84d9b 100644 --- a/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py @@ -2,17 +2,27 @@ Rutas para gestión de firmas """ +import mimetypes +import os +from datetime import datetime from typing import Any, Dict from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource -from fastapi import Depends, HTTPException, Query, status +from fastapi import Depends, File, HTTPException, Query, Response, UploadFile, status from sqlalchemy.orm import Session +from core.config import settings from core.database import get_core_db +from core.s3_keys import signature_photo_key +from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes from .dto import SignatureCreateDTO, SignatureResponseDTO, SignatureUpdateDTO from .service import SignatureService +# Constantes +ALLOWED_PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} +MAX_PHOTO_SIZE = 5 * 1024 * 1024 # 5 MB + # Create router using TenantCRUDRoutes factory signature_crud = TenantCRUDRoutes( service=SignatureService, @@ -81,3 +91,119 @@ async def delete_signature( detail="Signature not found", ) return None + + +@router.post( + "/{signature_id}/upload-photo", + response_model=dict, + summary="Upload signature photo", +) +async def upload_signature_photo( + signature_id: int, + company_id: int = Query(..., description="Company ID"), + file: UploadFile = File(...), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency), +): + """ + Sube la foto de la firma electrónica a MinIO/S3 y actualiza photo_path en la firma. + Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp — máx 5 MB. + """ + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_signatures.edit"] + ) + + # Verificar que la firma existe + sig = SignatureService.get_by_id(db, signature_id, tenant_id, company_id) + if not sig: + raise HTTPException(status_code=404, detail="Signature not found") + + # Validar extensión + file_ext = os.path.splitext(file.filename or "")[1].lower() + if file_ext not in ALLOWED_PHOTO_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Tipo de archivo no permitido. Permitidos: {', '.join(ALLOWED_PHOTO_EXTENSIONS)}", + ) + + # Leer y validar tamaño + content = await file.read() + if len(content) > MAX_PHOTO_SIZE: + raise HTTPException( + status_code=400, + detail=f"Archivo demasiado grande. Máximo: {MAX_PHOTO_SIZE // 1024 // 1024} MB", + ) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + try: + if settings.use_s3_object_storage: + # Eliminar foto anterior si existe en S3 + if sig.photo_path and sig.photo_path.startswith("tenants/"): + delete_object_if_exists(sig.photo_path) + + key = signature_photo_key(tenant_id, company_id, signature_id, timestamp, file_ext) + ct = mimetypes.guess_type(file.filename or "photo")[0] or "image/jpeg" + put_object_bytes(key, content, content_type=ct) + file_path = key + else: + # Fallback: almacenamiento local + upload_dir = os.path.join("uploads", "signatures", str(signature_id)) + os.makedirs(upload_dir, exist_ok=True) + filename = f"photo_{timestamp}{file_ext}" + file_path = os.path.join(upload_dir, filename) + with open(file_path, "wb") as f: + f.write(content) + except Exception as exc: + raise HTTPException( + status_code=500, + detail=f"Error al guardar la foto: {str(exc)}", + ) from exc + + # Actualizar photo_path en la firma + from .dto import SignatureUpdateDTO as UpdateDTO + SignatureService.update( + db, signature_id, tenant_id, UpdateDTO(photo_path=file_path), company_id + ) + + return { + "message": "Foto subida correctamente", + "photo_path": file_path, + "signature_id": signature_id, + } + + +@router.get( + "/{signature_id}/photo", + summary="Get signature photo image", +) +async def get_signature_photo( + signature_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency), +): + """Devuelve los bytes de la foto de la firma desde MinIO/S3 para preview.""" + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_signatures.view"] + ) + + sig = SignatureService.get_by_id(db, signature_id, tenant_id, company_id) + if not sig or not sig.photo_path: + raise HTTPException(status_code=404, detail="Foto no encontrada") + + if sig.photo_path.startswith("tenants/"): + try: + data = get_object_bytes(sig.photo_path) + except Exception: + raise HTTPException(status_code=404, detail="Foto no encontrada en el almacenamiento") + media_type = mimetypes.guess_type(sig.photo_path)[0] or "image/jpeg" + return Response(content=data, media_type=media_type) + + # Fallback: archivo local + if not os.path.isfile(sig.photo_path): + raise HTTPException(status_code=404, detail="Foto no encontrada en el sistema de archivos") + with open(sig.photo_path, "rb") as f: + data = f.read() + media_type = mimetypes.guess_type(sig.photo_path)[0] or "image/jpeg" + return Response(content=data, media_type=media_type) diff --git a/backend/api/v1/modules/public/reference_data/customs_sections/routes.py b/backend/api/v1/modules/public/reference_data/customs_sections/routes.py index e6710d8f..a78f3a6c 100644 --- a/backend/api/v1/modules/public/reference_data/customs_sections/routes.py +++ b/backend/api/v1/modules/public/reference_data/customs_sections/routes.py @@ -32,8 +32,8 @@ def list_customs_sections( search_filter = f"%{search}%" query = query.filter( or_( - CustomsSection.code.ilike(search_filter), - CustomsSection.description.ilike(search_filter) + CustomsSection.customs_code.ilike(search_filter), + CustomsSection.section_name.ilike(search_filter) ) ) items = query.offset(skip).limit(page_size).all() diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py index 8866b803..69593361 100644 --- a/backend/core/s3_keys.py +++ b/backend/core/s3_keys.py @@ -396,6 +396,26 @@ def help_public_api_path(relative_under_help: str) -> str: return f"/api/v1/core/help-center/files/{rel}" +def signature_photo_key( + tenant_id: Union[int, str], + company_id: int, + signature_id: int, + timestamp: str, + file_ext: str, +) -> str: + """ + Foto de firma bajo ``.../signatures/{signature_id}/photo_{timestamp}.{ext}``. + Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp + """ + sid = _segment(signature_id, "signature_id") + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp") + if ext not in allowed: + raise ValueError(f"signature photo extension not allowed: {ext}") + return f"{tenant_company_prefix(tenant_id, company_id)}signatures/{sid}/photo_{ts}{ext}" + + def system_help_object_key(relative_path: str) -> str: """ Clave S3 completa bajo ``system/help/`` para un path relativo (p. ej. GET /files/...). diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/signatures.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/signatures.ts index fe654100..13ea8fc6 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/signatures.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/signatures.ts @@ -92,5 +92,76 @@ export const signaturesApi = { get: (id: number, companyId: number) => getSignature(id, companyId), create: (data: SignatureCreate, companyId: number) => createSignature(data, companyId), update: (id: number, data: SignatureUpdate, companyId: number) => updateSignature(id, data, companyId), - delete: (id: number, companyId: number) => deleteSignature(id, companyId) -}; \ No newline at end of file + delete: (id: number, companyId: number) => deleteSignature(id, companyId), + uploadPhoto: (id: number, file: File, companyId: number) => uploadSignaturePhoto(id, file, companyId), + getPhotoUrl: (id: number, companyId: number) => getSignaturePhotoUrl(id, companyId), +}; + + +export interface SignaturePhotoUploadResponse { + message: string; + photo_path: string; + signature_id: number; +} + +/** + * Sube la foto de la firma a MinIO vía multipart/form-data. + * Usa fetch directo porque api.post() siempre JSON.stringify el body, + * lo que destruye el FormData. Replica la auth del cliente api.ts. + */ +export async function uploadSignaturePhoto( + signatureId: number, + file: File, + companyId: number +): Promise> { + const { getToken } = await import('$lib/auth'); + const { browser } = await import('$app/environment'); + + const apiBase = String(import.meta.env.VITE_API_URL ?? '').replace(/\/+$/, ''); + const token = getToken(); + + const formData = new FormData(); + formData.append('file', file); + + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + // Replicar X-Tenant-Override igual que fetchApi / buildAuthHeaders + if (browser) { + const tenantPub = document.cookie + .split('; ') + .find((c) => c.startsWith('sso_tenant_pub=')) + ?.split('=')[1]; + if (tenantPub) headers['X-Tenant-Override'] = tenantPub; + + const activeSystem = document.cookie + .split('; ') + .find((c) => c.startsWith('active_system=')) + ?.split('=')[1]; + if (activeSystem) headers['X-Active-System'] = activeSystem; + } + + try { + const res = await fetch( + `${apiBase}/v1/a76/signatures/${signatureId}/upload-photo?company_id=${companyId}`, + { method: 'POST', headers, credentials: 'include', body: formData } + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + return { error: data.detail || data.message || 'Error al subir la foto', status: res.status }; + } + return { data: data as SignaturePhotoUploadResponse, status: res.status }; + } catch { + return { error: 'Error de red al subir la foto', status: 0 }; + } +} + +/** + * Devuelve la URL del endpoint de foto para usar en . + * El backend sirve los bytes directamente desde MinIO con autenticación Bearer. + * NOTA: Esta URL no es pública, el componente debe agregarla via fetch+blob o + * usar el patrón de token en query param si se necesita en . + */ +export function getSignaturePhotoUrl(signatureId: number, companyId: number): string { + return `/api/v1/a76/signatures/${signatureId}/photo?company_id=${companyId}`; +} \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/general_catalogs/signatures/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/general_catalogs/signatures/create-edit-dialog.svelte index 0be5b8af..315a5fc7 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/signatures/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/signatures/create-edit-dialog.svelte @@ -3,14 +3,16 @@ import * as Dialog from '$lib/components/ui/dialog'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; - import { Textarea } from '$lib/components/ui/textarea'; // Para que quepa más texto en la firma - // 👇 Verifica tu ruta de importación + import { Textarea } from '$lib/components/ui/textarea'; + import { ImagePlus, Trash2, Loader2 } from 'lucide-svelte'; import { createSignature, updateSignature, + uploadSignaturePhoto, type Signature } from '$lib/api/dashboard/a76/general_catalogs/signatures'; import { companyStore } from '$lib/stores/company.svelte'; + import { getToken } from '$lib/auth'; import { obtenerAtajosFormularioFirmas } from '$lib/config/shortcuts/dashboard/general_catalogs/signatures/edit'; let { @@ -23,8 +25,6 @@ onSuccess?: () => void; } = $props(); - // Atajos - const isEdit = $derived(!!item); const title = $derived(isEdit ? 'Editar Firma' : 'Nueva Firma'); @@ -38,6 +38,12 @@ let loading = $state(false); let error = $state(null); + // Estado de la foto + let selectedFile = $state(null); + let previewUrl = $state(null); + let photoLoading = $state(false); + let fileInput = $state(null); + // Cargar datos al abrir $effect(() => { if (open) { @@ -47,17 +53,87 @@ signature: item.signature || '', photo_path: item.photo_path || '' }; + // Cargar preview si hay foto + if (item.photo_path && item.id) { + loadPhotoPreview(item.id); + } else { + previewUrl = null; + } } else { - formData = { - code: '', - signature: '', - photo_path: '' - }; + formData = { code: '', signature: '', photo_path: '' }; + previewUrl = null; } + selectedFile = null; error = null; + } else { + // Al cerrar, limpiar la blob URL para liberar memoria + if (previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + previewUrl = null; + selectedFile = null; } }); + /** + * Carga la imagen desde el backend (autenticado) y crea una blob URL para el . + * Usa la misma base URL y headers que el cliente api.ts. + */ + async function loadPhotoPreview(signatureId: number) { + const companyId = companyStore.activeCompany?.id; + if (!companyId) return; + + photoLoading = true; + try { + const token = getToken(); + // Misma URL base que usa fetchApi en api.ts + const apiBase = String(import.meta.env.VITE_API_URL ?? '').replace(/\/+$/, ''); + const url = `${apiBase}/v1/a76/signatures/${signatureId}/photo?company_id=${companyId}`; + + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + // Agregar X-Tenant-Override si existe (igual que buildAuthHeaders) + const tenantPub = document.cookie + .split('; ') + .find((c) => c.startsWith('sso_tenant_pub=')) + ?.split('=')[1]; + if (tenantPub) headers['X-Tenant-Override'] = tenantPub; + + const res = await fetch(url, { headers, credentials: 'include' }); + if (!res.ok) { + previewUrl = null; + return; + } + const blob = await res.blob(); + if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl); + previewUrl = URL.createObjectURL(blob); + } catch { + previewUrl = null; + } finally { + photoLoading = false; + } + } + + function handleFileChange(event: Event) { + const target = event.target as HTMLInputElement; + const file = target.files?.[0] ?? null; + selectedFile = file; + + if (file) { + if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl); + previewUrl = URL.createObjectURL(file); + } + } + + function clearPhoto() { + selectedFile = null; + if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl); + previewUrl = null; + formData.photo_path = ''; + if (fileInput) fileInput.value = ''; + } + async function handleSubmit() { loading = true; error = null; @@ -65,11 +141,8 @@ try { const companyId = companyStore.activeCompany?.id; if (!companyId) throw new Error('No hay una compañía seleccionada'); - - // Validaciones if (!formData.code.trim()) throw new Error('El código es requerido'); - // Preparar datos const dataToSend = { code: formData.code.trim(), signature: formData.signature.trim() || null, @@ -77,12 +150,26 @@ }; let response; + let savedSignatureId: number | undefined; - // 👇 companyId por fuera (Argumento separado) if (isEdit && item) { + // Primero actualizar los campos de texto response = await updateSignature(item.id, dataToSend, companyId); + savedSignatureId = item.id; } else { + // Crear la firma (sin foto aún) response = await createSignature(dataToSend, companyId); + savedSignatureId = (response as Signature)?.id; + } + + // Si hay un archivo nuevo seleccionado, subirlo ahora que tenemos el ID + if (selectedFile && savedSignatureId) { + const uploadResult = await uploadSignaturePhoto(savedSignatureId, selectedFile, companyId); + if (uploadResult.error) { + // La firma se guardó pero la foto falló: informar sin bloquear + error = `Firma guardada, pero hubo un error al subir la foto: ${uploadResult.error}`; + // Aún así cerrar y recargar + } } open = false; @@ -115,9 +202,9 @@ {/if}
+
- +
-

Máximo 10 caracteres.

+

Máximo 10 caracteres.

+
@@ -144,17 +232,66 @@
-
- -
- +
+ +
+ + {#if photoLoading} +
+ +
+ {:else if previewUrl} +
+ Vista previa de firma + {#if !loading} + + {/if} +
+ {:else} +
+ Sin foto +
+ {/if} + + + -

Ruta del archivo (Texto).

+ +

+ JPG, PNG, GIF o WebP — máx. 5 MB. + {#if !isEdit} + La foto se sube al guardar. + {/if} +