feat(crm): Prospecto — medio de contacto preferido + fix visualización de documentos (Fase C)

- Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido"
  (catálogo medio_contacto). Backend leads.preferred_contact_method + migración
  f0a1b2c3d4e5 reversible.
- Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo
  por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al
  host interno minio:9000. RelatedManager.openDoc usa blob→objectURL.

Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-08-07 07:37:30 -06:00
parent 8c7aeef1a6
commit 8431132b10
8 changed files with 89 additions and 6 deletions

View File

@@ -192,6 +192,7 @@ export interface Lead {
phone: string | null;
company_name: string | null;
source: string | null;
preferred_contact_method: string | null;
status: LeadStatus;
estimated_value: number | null;
owner_user_id: string | null;

View File

@@ -31,3 +31,10 @@ export async function uploadUrl(fileKey: string, companyId: number): Promise<str
if (res.error) throw new Error(res.error);
return res.data!.url;
}
/** Descarga el archivo por el backend (sin exponer MinIO) y devuelve un blob. */
export async function downloadBlob(fileKey: string, companyId: number): Promise<Blob> {
return (api as any).getBlob(
`/v1/crm/uploads/download?key=${encodeURIComponent(fileKey)}&company_id=${companyId}`
) as Promise<Blob>;
}

View File

@@ -11,7 +11,7 @@
import { DOC_TYPES, labelOf } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { onMount } from 'svelte';
import { uploadFile, uploadUrl } from '$lib/api/uploads';
import { uploadFile, downloadBlob } from '$lib/api/uploads';
import { toast } from 'svelte-sonner';
onMount(() => {
@@ -62,9 +62,17 @@
async function openDoc(d: Document) {
if (!companyId) return;
try {
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
if (url) window.open(url, '_blank', 'noopener');
else toast.error('El documento no tiene archivo');
if (d.file_key) {
// Descarga por el backend (evita exponer MinIO / host interno)
const blob = await downloadBlob(d.file_key, companyId);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(url), 60000);
} else if (d.file_url) {
window.open(d.file_url, '_blank', 'noopener');
} else {
toast.error('El documento no tiene archivo');
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
}

View File

@@ -4,10 +4,14 @@
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { onMount } from 'svelte';
import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm';
import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
onMount(() => void crmCatalogs.ensure('medio_contacto'));
let items = $state<Lead[]>([]);
let loading = $state(false);
let search = $state('');
@@ -233,6 +237,13 @@
{#each LEAD_SOURCES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Medio de contacto preferido</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.preferred_contact_method}>
<option value={undefined}>—</option>
{#each crmCatalogs.options('medio_contacto') as m (m.value)}<option value={m.value}>{m.label}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Estado</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.status}>