Se aniado el boton para subir fotografias desde el equipo, ademas de guardardo en minio

This commit is contained in:
2026-06-01 17:13:00 -05:00
parent 2ccf1b4879
commit af6c0698da
4 changed files with 382 additions and 28 deletions

View File

@@ -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)
};
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<ApiResponse<SignaturePhotoUploadResponse>> {
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<string, string> = {};
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 <img src=...>.
* 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 <img>.
*/
export function getSignaturePhotoUrl(signatureId: number, companyId: number): string {
return `/api/v1/a76/signatures/${signatureId}/photo?company_id=${companyId}`;
}

View File

@@ -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<string | null>(null);
// Estado de la foto
let selectedFile = $state<File | null>(null);
let previewUrl = $state<string | null>(null);
let photoLoading = $state(false);
let fileInput = $state<HTMLInputElement | null>(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 <img>.
* 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<string, string> = {};
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}
<div class="grid gap-4">
<!-- Código -->
<div class="grid grid-cols-4 items-center gap-4">
<Label for="code" class="text-right">Código <span class="text-destructive">*</span></Label
>
<Label for="code" class="text-right">Código <span class="text-destructive">*</span></Label>
<div class="col-span-3">
<Input
id="code"
@@ -127,10 +214,11 @@
disabled={loading || isEdit}
required
/>
<p class="text-[10px] text-muted-foreground mt-1">Máximo 10 caracteres.</p>
<p class="mt-1 text-[10px] text-muted-foreground">Máximo 10 caracteres.</p>
</div>
</div>
<!-- Firma / Nombre -->
<div class="grid grid-cols-4 items-center gap-4">
<Label for="signature" class="text-right">Firma / Nombre</Label>
<div class="col-span-3">
@@ -144,17 +232,66 @@
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="photo_path" class="text-right">Ruta Foto</Label>
<div class="col-span-3">
<Input
id="photo_path"
bind:value={formData.photo_path}
placeholder="Ej: /uploads/firmas/juan.png"
maxlength={1000}
<!-- Foto de firma -->
<div class="grid grid-cols-4 items-start gap-4">
<Label class="pt-2 text-right">Foto</Label>
<div class="col-span-3 flex flex-col gap-2">
<!-- Preview -->
{#if photoLoading}
<div class="flex h-28 w-full items-center justify-center rounded-md border border-dashed bg-muted/30">
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div>
{:else if previewUrl}
<div class="relative w-fit">
<img
src={previewUrl}
alt="Vista previa de firma"
class="h-28 max-w-full rounded-md border object-contain shadow-sm"
/>
{#if !loading}
<button
type="button"
onclick={clearPhoto}
class="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow"
title="Eliminar foto"
>
<Trash2 class="h-3 w-3" />
</button>
{/if}
</div>
{:else}
<div class="flex h-28 w-full items-center justify-center rounded-md border border-dashed bg-muted/30 text-muted-foreground">
<span class="text-xs">Sin foto</span>
</div>
{/if}
<!-- Selector de archivo -->
<input
bind:this={fileInput}
type="file"
id="photo_file"
accept=".jpg,.jpeg,.png,.gif,.webp"
class="hidden"
onchange={handleFileChange}
disabled={loading}
/>
<p class="text-[10px] text-muted-foreground mt-1">Ruta del archivo (Texto).</p>
<Button
type="button"
variant="outline"
size="sm"
class="w-fit"
onclick={() => fileInput?.click()}
disabled={loading}
>
<ImagePlus class="mr-2 h-4 w-4" />
{previewUrl ? 'Cambiar foto' : 'Seleccionar foto'}
</Button>
<p class="text-[10px] text-muted-foreground">
JPG, PNG, GIF o WebP — máx. 5 MB.
{#if !isEdit}
La foto se sube al guardar.
{/if}
</p>
</div>
</div>
</div>