feat(crm): cotización — Ver PDF, Enviar por correo y pantalla de marca por tenant
- Cliente API: quotesAPI.pdfUrl/sendEmail + quoteSettingsAPI (get/save/uploadLogo). - Cotización: botones Ver PDF y Enviar por correo (modal con destinatario/asunto/mensaje). - Configuración → Formato de cotización: emisor, logo, color, prefijo y textos por defecto (por compañía). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,7 +157,32 @@ export const quotesAPI = {
|
||||
reject: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/reject?${qp(companyId)}`, {})),
|
||||
clone: (id: number, companyId: number) => unwrap<Quote>(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})),
|
||||
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)),
|
||||
items: (quoteId: number, companyId: number) => unwrap<QuoteItem[]>(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`))
|
||||
items: (quoteId: number, companyId: number) => unwrap<QuoteItem[]>(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)),
|
||||
pdfUrl: (id: number, companyId: number) => unwrap<{ url: string }>(api.get(`/v1/crm/quotes/${id}/pdf-url?${qp(companyId)}`)),
|
||||
sendEmail: (id: number, companyId: number, body: { to?: string | null; subject?: string | null; message?: string | null }) =>
|
||||
unwrap<{ sent_to: string; reference: string }>(api.post(`/v1/crm/quotes/${id}/send-email?${qp(companyId)}`, body))
|
||||
};
|
||||
|
||||
// ---------- Configuración de marca del formato de cotización ----------
|
||||
export interface QuoteSettings {
|
||||
id?: number | null;
|
||||
emitter_name?: string | null; emitter_rfc?: string | null; emitter_address?: string | null;
|
||||
emitter_phone?: string | null; emitter_email?: string | null; emitter_website?: string | null;
|
||||
logo_file_key?: string | null; accent_color?: string | null; quote_prefix?: string | null;
|
||||
default_terms?: string | null; footer_note?: string | null;
|
||||
}
|
||||
|
||||
export const quoteSettingsAPI = {
|
||||
get: (companyId: number) => unwrap<QuoteSettings>(api.get(`/v1/crm/quote-settings?${qp(companyId)}`)),
|
||||
save: (companyId: number, data: QuoteSettings) => unwrap<QuoteSettings>(api.put(`/v1/crm/quote-settings?${qp(companyId)}`, data)),
|
||||
logoUrl: (companyId: number) => unwrap<{ url: string | null }>(api.get(`/v1/crm/quote-settings/logo-url?${qp(companyId)}`)),
|
||||
async uploadLogo(companyId: number, file: File): Promise<QuoteSettings> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await (api as any).request(`/v1/crm/quote-settings/logo?${qp(companyId)}`, { method: 'POST', body: fd });
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data as QuoteSettings;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- Catálogos de referencia (Incoterms, participantes) ----------
|
||||
|
||||
@@ -92,6 +92,10 @@ export function getNavMain(): NavMainItem[] {
|
||||
title: 'Configuración',
|
||||
url: '/dashboard/settings/general',
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{ title: 'General', url: '/dashboard/settings/general' },
|
||||
{ title: 'Formato de cotización', url: '/dashboard/settings/cotizacion' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship } from '@lucide/svelte';
|
||||
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship, FileText, Mail } from '@lucide/svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -142,6 +142,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openPdf() {
|
||||
if (!companyId || !quote) return;
|
||||
busy = true;
|
||||
try {
|
||||
const { url } = await quotesAPI.pdfUrl(quote.id, companyId);
|
||||
if (url) window.open(url, '_blank', 'noopener');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo generar el PDF');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
let showEmail = $state(false);
|
||||
let emailForm = $state({ to: '', subject: '', message: '' });
|
||||
function openEmail() {
|
||||
emailForm = {
|
||||
to: accounts.find((a) => a.id === quote?.account_id)?.email ?? '',
|
||||
subject: `Cotización ${quote?.reference ?? ''}`.trim(),
|
||||
message: 'Adjunto la cotización solicitada. Quedamos atentos a sus comentarios.'
|
||||
};
|
||||
showEmail = true;
|
||||
}
|
||||
async function sendEmail() {
|
||||
if (!companyId || !quote) return;
|
||||
if (!emailForm.to.trim()) { toast.error('Indica el correo destino'); return; }
|
||||
busy = true;
|
||||
try {
|
||||
const res = await quotesAPI.sendEmail(quote.id, companyId, {
|
||||
to: emailForm.to.trim(), subject: emailForm.subject || null, message: emailForm.message || null
|
||||
});
|
||||
toast.success(`Cotización enviada a ${res.sent_to}`);
|
||||
showEmail = false;
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo enviar el correo');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function supplierName(id: number | null | undefined): string {
|
||||
return suppliers.find((s) => s.id === id)?.name ?? '—';
|
||||
}
|
||||
@@ -169,8 +210,10 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onclick={openPdf} disabled={busy}><FileText class="mr-1 h-4 w-4" /> Ver PDF</Button>
|
||||
<Button size="sm" variant="outline" onclick={openEmail} disabled={busy}><Mail class="mr-1 h-4 w-4" /> Enviar por correo</Button>
|
||||
{#if quote.status === 'borrador'}
|
||||
<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Enviar</Button>
|
||||
<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Marcar enviada</Button>
|
||||
{/if}
|
||||
{#if quote.status === 'enviada'}
|
||||
<Button size="sm" variant="outline" onclick={() => doAction('accept')} disabled={busy}><Check class="mr-1 h-4 w-4" /> Aceptar</Button>
|
||||
@@ -249,3 +292,21 @@
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showEmail && quote}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (showEmail = false)}>
|
||||
<div class="w-full max-w-lg rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
<h3 class="mb-4 flex items-center gap-2 text-base font-semibold"><Mail class="h-4 w-4" /> Enviar cotización por correo</h3>
|
||||
<div class="grid gap-3">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Para *</span><input type="email" class={inputCls} bind:value={emailForm.to} placeholder="cliente@empresa.com" /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Asunto</span><input class={inputCls} bind:value={emailForm.subject} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Mensaje</span><textarea rows="4" class={inputCls} bind:value={emailForm.message}></textarea></label>
|
||||
<p class="text-xs text-muted-foreground">Se adjunta el PDF de la cotización con el formato y la marca configurados.</p>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" onclick={() => (showEmail = false)}>Cancelar</Button>
|
||||
<Button onclick={sendEmail} disabled={busy}>{busy ? 'Enviando…' : 'Enviar'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
104
frontend/src/routes/dashboard/settings/cotizacion/+page.svelte
Normal file
104
frontend/src/routes/dashboard/settings/cotizacion/+page.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { FileText, Upload } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { quoteSettingsAPI, type QuoteSettings } from '$lib/api/crm';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
let s = $state<QuoteSettings>({});
|
||||
let logoUrl = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let uploading = $state(false);
|
||||
|
||||
$effect(() => { const cid = companyId; if (cid) void load(cid); });
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
s = await quoteSettingsAPI.get(cid);
|
||||
logoUrl = (await quoteSettingsAPI.logoUrl(cid).catch(() => ({ url: null }))).url;
|
||||
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar la configuración'); }
|
||||
finally { loading = false; }
|
||||
}
|
||||
async function save() {
|
||||
if (!companyId) return;
|
||||
saving = true;
|
||||
try { s = await quoteSettingsAPI.save(companyId, s); toast.success('Configuración guardada'); }
|
||||
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo guardar'); }
|
||||
finally { saving = false; }
|
||||
}
|
||||
async function onLogo(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file || !companyId) return;
|
||||
uploading = true;
|
||||
try {
|
||||
s = await quoteSettingsAPI.uploadLogo(companyId, file);
|
||||
logoUrl = (await quoteSettingsAPI.logoUrl(companyId)).url;
|
||||
toast.success('Logo actualizado');
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : 'No se pudo subir el logo'); }
|
||||
finally { uploading = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> Formato de cotización</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Marca y encabezados que se imprimen en el PDF de las cotizaciones (por compañía).</p>
|
||||
</div>
|
||||
|
||||
{#if !companyId}
|
||||
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Header><Card.Title class="text-base">Logo</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt="Logo" class="h-16 rounded border bg-white object-contain p-1" />
|
||||
{:else}
|
||||
<div class="flex h-16 w-32 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">Sin logo</div>
|
||||
{/if}
|
||||
<label class="cursor-pointer">
|
||||
<input type="file" accept="image/*" class="hidden" onchange={onLogo} />
|
||||
<span class="inline-flex items-center gap-1 rounded-md border px-3 py-2 text-sm hover:bg-muted"><Upload class="h-4 w-4" /> {uploading ? 'Subiendo…' : 'Subir logo'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header><Card.Title class="text-base">Datos del emisor</Card.Title>
|
||||
<Card.Description>Aparecen en el encabezado del PDF.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Razón social (emisor)</span><input class={inputCls} bind:value={s.emitter_name} placeholder="Mi Agencia SA de CV" /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">RFC</span><input class="font-mono {inputCls}" maxlength="13" bind:value={s.emitter_rfc} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={s.emitter_phone} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Correo</span><input class={inputCls} bind:value={s.emitter_email} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Sitio web</span><input class={inputCls} bind:value={s.emitter_website} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Dirección</span><textarea rows="2" class={inputCls} bind:value={s.emitter_address}></textarea></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Color de acento</span><input type="color" class="{inputCls} h-10 p-1" bind:value={s.accent_color} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Prefijo de folio</span><input class={inputCls} maxlength="12" bind:value={s.quote_prefix} placeholder="COT" /></label>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header><Card.Title class="text-base">Textos por defecto</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid gap-4">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Condiciones comerciales (por defecto)</span><textarea rows="5" class={inputCls} bind:value={s.default_terms} placeholder="Una condición por línea…"></textarea></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Pie de página</span><input class={inputCls} bind:value={s.footer_note} /></label>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<div class="flex justify-end"><Button onclick={save} disabled={saving || loading}>{saving ? 'Guardando…' : 'Guardar configuración'}</Button></div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user