Se aniado el boton para subir fotografias desde el equipo, ademas de guardardo en minio
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user