F4: make SMTP configurable from Superadmin and remove Director
This commit is contained in:
@@ -1,17 +1,25 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
|
import { SearchableSelect } from '../components/SearchableSelect';
|
||||||
import {
|
import {
|
||||||
getDocumentDeliverySettings,
|
getDocumentDeliverySettingsF4,
|
||||||
listDocumentDeliveries,
|
listDocumentDeliveriesF4,
|
||||||
retryDocumentDelivery,
|
retryDocumentDeliveryF4,
|
||||||
retryPendingDocumentDeliveries,
|
retryPendingDocumentDeliveriesF4,
|
||||||
updateDocumentDeliverySettings,
|
updateDocumentDeliverySettingsF4,
|
||||||
} from '../lib/api';
|
} from '../lib/documentDeliveryF4Api';
|
||||||
import type { DocumentDeliveryItem, DocumentDeliverySettings } from '../lib/api';
|
import type { DocumentDeliveryItemF4, DocumentDeliverySettingsF4 } from '../lib/documentDeliveryF4Api';
|
||||||
|
import {
|
||||||
|
getSmtpSettings,
|
||||||
|
saveSmtpSettings,
|
||||||
|
testSmtpSettings,
|
||||||
|
} from '../lib/smtpSettingsApi';
|
||||||
|
import type { PublicSmtpSettings, SmtpSecurityMode } from '../lib/smtpSettingsApi';
|
||||||
|
|
||||||
const statusLabel: Record<DocumentDeliveryItem['status'], string> = {
|
const statusLabel: Record<DocumentDeliveryItemF4['status'], string> = {
|
||||||
PENDING: 'Pendiente',
|
PENDING: 'Pendiente',
|
||||||
WAITING_RECIPIENT: 'Falta destinatario',
|
WAITING_RECIPIENT: 'Falta destinatario',
|
||||||
WAITING_TRANSPORT: 'SMTP sin configurar',
|
WAITING_TRANSPORT: 'SMTP sin configurar',
|
||||||
@@ -20,125 +28,165 @@ const statusLabel: Record<DocumentDeliveryItem['status'], string> = {
|
|||||||
FAILED: 'Error',
|
FAILED: 'Error',
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeliveryWithInspector = DocumentDeliveryItem & {
|
function recipientLabel(item: DocumentDeliveryItemF4): string {
|
||||||
recipientUserId?: string | null;
|
|
||||||
recipientUserName?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function recipientLabel(item: DeliveryWithInspector): string {
|
|
||||||
if (item.recipientAssetName) return item.recipientAssetName;
|
if (item.recipientAssetName) return item.recipientAssetName;
|
||||||
if (item.recipientUserName) return item.recipientUserName;
|
if (item.recipientUserName) return item.recipientUserName;
|
||||||
const kind = String(item.recipientKind);
|
if (item.recipientKind === 'COMPANY') return 'Empresa sin identificar';
|
||||||
if (kind === 'COMPANY') return 'Empresa sin identificar';
|
if (item.recipientKind === 'OFFICE') return 'Oficina';
|
||||||
if (kind === 'OFFICE') return 'Oficina';
|
return 'Inspector responsable';
|
||||||
if (kind === 'INSPECTOR') return 'Inspector responsable';
|
}
|
||||||
return 'Director';
|
|
||||||
|
function defaultPort(mode: SmtpSecurityMode): number {
|
||||||
|
return mode === 'TLS' ? 465 : mode === 'STARTTLS' ? 587 : 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentDeliveryPage() {
|
export function DocumentDeliveryPage() {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canManage = hasPermission('document_delivery.manage');
|
const canManage = hasPermission('document_delivery.manage');
|
||||||
const [settings, setSettings] = useState<DocumentDeliverySettings | null>(null);
|
const [settings, setSettings] = useState<DocumentDeliverySettingsF4 | null>(null);
|
||||||
const [items, setItems] = useState<DocumentDeliveryItem[]>([]);
|
const [smtp, setSmtp] = useState<PublicSmtpSettings | null>(null);
|
||||||
|
const [items, setItems] = useState<DocumentDeliveryItemF4[]>([]);
|
||||||
const [officeEmail, setOfficeEmail] = useState('');
|
const [officeEmail, setOfficeEmail] = useState('');
|
||||||
const [directorEmail, setDirectorEmail] = useState('');
|
const [host, setHost] = useState('');
|
||||||
|
const [port, setPort] = useState('587');
|
||||||
|
const [securityMode, setSecurityMode] = useState<SmtpSecurityMode>('STARTTLS');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [fromName, setFromName] = useState('DH Inspección');
|
||||||
|
const [fromEmail, setFromEmail] = useState('');
|
||||||
|
const [replyTo, setReplyTo] = useState('');
|
||||||
|
const [smtpEnabled, setSmtpEnabled] = useState(true);
|
||||||
|
const [testEmail, setTestEmail] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
|
const applySmtp = (value: PublicSmtpSettings) => {
|
||||||
|
setSmtp(value);
|
||||||
|
setHost(value.host ?? '');
|
||||||
|
setPort(String(value.port ?? defaultPort(value.securityMode ?? 'STARTTLS')));
|
||||||
|
setSecurityMode(value.securityMode ?? 'STARTTLS');
|
||||||
|
setUsername(value.username ?? '');
|
||||||
|
setPassword('');
|
||||||
|
setFromName(value.fromName ?? 'DH Inspección');
|
||||||
|
setFromEmail(value.fromEmail ?? '');
|
||||||
|
setReplyTo(value.replyTo ?? '');
|
||||||
|
setSmtpEnabled(value.enabled);
|
||||||
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [currentSettings, outbox] = await Promise.all([
|
const [currentSettings, outbox, smtpSettings] = await Promise.all([
|
||||||
getDocumentDeliverySettings(),
|
getDocumentDeliverySettingsF4(),
|
||||||
listDocumentDeliveries(),
|
listDocumentDeliveriesF4(),
|
||||||
|
canManage ? getSmtpSettings() : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
setSettings(currentSettings);
|
setSettings(currentSettings);
|
||||||
setOfficeEmail(currentSettings.officeEmail ?? '');
|
setOfficeEmail(currentSettings.officeEmail ?? '');
|
||||||
setDirectorEmail(currentSettings.directorEmail ?? '');
|
|
||||||
setItems(outbox.data);
|
setItems(outbox.data);
|
||||||
|
if (smtpSettings) applySmtp(smtpSettings);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||||
}, []);
|
}, [canManage]);
|
||||||
|
|
||||||
const save = async (event: FormEvent) => {
|
const saveOffice = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true); setError(''); setSuccess('');
|
||||||
setError('');
|
|
||||||
setSuccess('');
|
|
||||||
try {
|
try {
|
||||||
const currentSettings = await updateDocumentDeliverySettings({
|
const updated = await updateDocumentDeliverySettingsF4(officeEmail.trim() || null);
|
||||||
officeEmail: officeEmail || null,
|
setSettings(updated);
|
||||||
directorEmail: directorEmail || null,
|
setSuccess('Destinatario institucional de oficina actualizado.');
|
||||||
});
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||||
setSettings(currentSettings);
|
finally { setSaving(false); }
|
||||||
setSuccess('Destinatarios institucionales actualizados');
|
};
|
||||||
} catch (requestError) {
|
|
||||||
setError(errorMessage(requestError));
|
const saveSmtp = async (event: FormEvent) => {
|
||||||
} finally {
|
event.preventDefault();
|
||||||
setSaving(false);
|
if (!host.trim() || !fromName.trim() || !fromEmail.trim()) return;
|
||||||
}
|
setSaving(true); setError(''); setSuccess('');
|
||||||
|
try {
|
||||||
|
const input: Parameters<typeof saveSmtpSettings>[0] = {
|
||||||
|
host: host.trim(),
|
||||||
|
port: Number(port),
|
||||||
|
securityMode,
|
||||||
|
username: username.trim() || null,
|
||||||
|
fromName: fromName.trim(),
|
||||||
|
fromEmail: fromEmail.trim(),
|
||||||
|
replyTo: replyTo.trim() || null,
|
||||||
|
enabled: smtpEnabled,
|
||||||
|
};
|
||||||
|
if (password) input.password = password;
|
||||||
|
const updated = await saveSmtpSettings(input);
|
||||||
|
applySmtp(updated);
|
||||||
|
setSuccess('Configuración SMTP guardada. La contraseña no se devuelve ni se muestra en pantalla.');
|
||||||
|
const refreshed = await getDocumentDeliverySettingsF4();
|
||||||
|
setSettings(refreshed);
|
||||||
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTest = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!testEmail.trim()) return;
|
||||||
|
setSaving(true); setError(''); setSuccess('');
|
||||||
|
try {
|
||||||
|
const result = await testSmtpSettings(testEmail.trim());
|
||||||
|
setSuccess(`Prueba SMTP enviada a ${result.recipient}.`);
|
||||||
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||||
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryAll = async () => {
|
const retryAll = async () => {
|
||||||
setSaving(true);
|
setSaving(true); setError(''); setSuccess('');
|
||||||
setError('');
|
|
||||||
setSuccess('');
|
|
||||||
try {
|
try {
|
||||||
const result = await retryPendingDocumentDeliveries();
|
const result = await retryPendingDocumentDeliveriesF4();
|
||||||
await load();
|
await load();
|
||||||
setSuccess(`Se procesaron ${result.processed} entrega/s pendientes.`);
|
setSuccess(`Se procesaron ${result.processed} entrega/s pendientes.`);
|
||||||
} catch (requestError) {
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||||
setError(errorMessage(requestError));
|
finally { setSaving(false); }
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <LoadingBlock label="Cargando entrega documental…" />;
|
if (loading) return <LoadingBlock label="Cargando entrega documental…" />;
|
||||||
|
|
||||||
return <section className="narrow-section">
|
return <section className="narrow-section">
|
||||||
<div className="page-heading">
|
<div className="page-heading"><div><span className="eyebrow">SUPERADMIN · CORREO</span><h1>Entrega documental</h1><p>Configuración institucional de SMTP, destinatarios y trazabilidad de los envíos automáticos.</p></div>{canManage && <button className="button secondary" onClick={retryAll} disabled={saving}><Icon name="history" />Reintentar pendientes</button>}</div>
|
||||||
<div>
|
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||||
<span className="eyebrow">DOCUMENTOS</span>
|
|
||||||
<h1>Entrega documental</h1>
|
|
||||||
<p>Configurá los destinatarios institucionales y controlá el envío automático de Actas e Informes.</p>
|
|
||||||
</div>
|
|
||||||
{canManage && <button className="button secondary" onClick={retryAll} disabled={saving}><Icon name="history" />Reintentar pendientes</button>}
|
|
||||||
</div>
|
|
||||||
{error && <Alert>{error}</Alert>}
|
|
||||||
{success && <Alert type="success">{success}</Alert>}
|
|
||||||
|
|
||||||
<form className="panel form-panel" onSubmit={save}>
|
<form className="panel form-panel" onSubmit={saveOffice}>
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<div>
|
<div><h2>Destinatarios institucionales</h2><p className="section-copy">La empresa usa el email de su ficha de Inventario. El Inspector usa el email de su Usuario. Acá se configura únicamente la copia institucional de oficina.</p></div>
|
||||||
<h2>Destinatarios institucionales</h2>
|
<label className="field"><span>Email de oficina</span><input type="email" value={officeEmail} onChange={(event) => setOfficeEmail(event.target.value)} disabled={!canManage} /></label>
|
||||||
<p className="section-copy">El email de cada empresa se configura dentro de su ficha de Inventario. El Inspector responsable usa automáticamente el email declarado en su Usuario. Las credenciales SMTP permanecen fuera de la base de datos.</p>
|
<div className="temporal-notice"><Icon name={settings?.smtpConfigured ? 'check' : 'alert'} /><p><strong>{settings?.smtpConfigured ? 'Salida SMTP operativa' : 'Salida SMTP pendiente'}</strong>{settings?.mailFrom ? ` · Remitente ${settings.mailFrom}` : ''}</p></div>
|
||||||
</div>
|
{canManage && <div className="form-actions"><button className="button primary" disabled={saving}>Guardar destinatario</button></div>}
|
||||||
<div className="form-grid">
|
|
||||||
<label className="field"><span>Email de oficina</span><input type="email" value={officeEmail} onChange={(event) => setOfficeEmail(event.target.value)} disabled={!canManage} /></label>
|
|
||||||
<label className="field"><span>Email del Director de Hidrocarburos</span><input type="email" value={directorEmail} onChange={(event) => setDirectorEmail(event.target.value)} disabled={!canManage} /></label>
|
|
||||||
</div>
|
|
||||||
<div className="temporal-notice"><Icon name={settings?.smtpConfigured ? 'check' : 'alert'} /><p><strong>{settings?.smtpConfigured ? 'Servidor de correo configurado' : 'Servidor de correo pendiente'}</strong>{settings?.mailFrom ? ` · Remitente ${settings.mailFrom}` : ' · Falta configurar SMTP_HOST y MAIL_FROM en el servidor.'}</p></div>
|
|
||||||
{canManage && <div className="form-actions"><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar destinatarios'}</button></div>}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="panel">
|
{canManage && <form className="panel form-panel" onSubmit={saveSmtp}>
|
||||||
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Últimas entregas</h2></div><span>{items.length}</span></div>
|
<div className="form-section">
|
||||||
{items.length === 0
|
<div><span className="eyebrow">SALIDA DE EMAIL</span><h2>Servidor SMTP</h2><p className="section-copy">La contraseña se cifra en el servidor con AES-256-GCM. Al volver a esta pantalla sólo se informa si existe una clave guardada; nunca se devuelve su contenido.</p></div>
|
||||||
? <div className="inline-empty">Todavía no existen entregas documentales.</div>
|
{smtp?.source === 'ENVIRONMENT' && <Alert type="info">Actualmente se está usando la configuración del servidor. Al guardar este formulario, la configuración de base de datos administrable pasará a tener prioridad.</Alert>}
|
||||||
: <div className="table-wrap"><table><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th></th></tr></thead><tbody>{items.map((baseItem) => {
|
<div className="form-grid">
|
||||||
const item = baseItem as DeliveryWithInspector;
|
<label className="field"><span>Host SMTP</span><input value={host} onChange={(event) => setHost(event.target.value)} required maxLength={255} placeholder="smtp.ejemplo.com" /></label>
|
||||||
return <tr key={item.id}>
|
<label className="field"><span>Puerto</span><input type="number" min={1} max={65535} value={port} onChange={(event) => setPort(event.target.value)} required /></label>
|
||||||
<td><strong>{item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode}</strong><small className="block-muted">{item.documentKind === 'ACT_PDF' ? 'Acta PDF' : 'Informe Word'}</small></td>
|
<label className="field"><span>Seguridad</span><SearchableSelect value={securityMode} onChange={(event) => { const mode = event.target.value as SmtpSecurityMode; setSecurityMode(mode); setPort(String(defaultPort(mode))); }}><option value="STARTTLS">STARTTLS · habitual 587</option><option value="TLS">TLS implícito · habitual 465</option><option value="NONE">Sin cifrado · sólo redes controladas</option></SearchableSelect></label>
|
||||||
<td>{recipientLabel(item)}<small className="block-muted">{item.recipientEmail ?? 'Sin email configurado'}</small></td>
|
<label className="field"><span>Usuario <em>opcional</em></span><input value={username} onChange={(event) => setUsername(event.target.value)} maxLength={255} autoComplete="username" /></label>
|
||||||
<td><span className={`status-badge ${item.status === 'SENT' ? 'active' : item.status === 'FAILED' ? 'inactive' : 'pending'}`}>{statusLabel[item.status]}</span>{item.lastError && <small className="block-muted">{item.lastError}</small>}</td>
|
<label className="field"><span>Contraseña {smtp?.hasPassword && <em>· ya existe una guardada</em>}</span><input type="password" value={password} onChange={(event) => setPassword(event.target.value)} maxLength={1000} autoComplete="new-password" placeholder={smtp?.hasPassword ? 'Dejar vacío para conservar la actual' : 'Contraseña SMTP'} /></label>
|
||||||
<td>{item.attempts}</td>
|
<label className="field"><span>Nombre remitente</span><input value={fromName} onChange={(event) => setFromName(event.target.value)} required maxLength={200} /></label>
|
||||||
<td>{canManage && item.status !== 'SENT' && <button className="button text" type="button" disabled={saving} onClick={async () => { setSaving(true); try { await retryDocumentDelivery(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar</button>}</td>
|
<label className="field"><span>Email remitente</span><input type="email" value={fromEmail} onChange={(event) => setFromEmail(event.target.value)} required maxLength={320} /></label>
|
||||||
</tr>;
|
<label className="field"><span>Reply-To <em>opcional</em></span><input type="email" value={replyTo} onChange={(event) => setReplyTo(event.target.value)} maxLength={320} /></label>
|
||||||
})}</tbody></table></div>}
|
</div>
|
||||||
</div>
|
<label className="inspection-member selected"><input type="checkbox" checked={smtpEnabled} onChange={(event) => setSmtpEnabled(event.target.checked)} /><span><strong>Habilitar envío de correo</strong><small>Si se desactiva, las entregas quedan en espera sin perderse.</small></span></label>
|
||||||
|
<div className="form-actions"><button className="button primary" disabled={saving || !host.trim() || !fromName.trim() || !fromEmail.trim()}>{saving ? 'Guardando…' : 'Guardar SMTP'}</button></div>
|
||||||
|
</div>
|
||||||
|
</form>}
|
||||||
|
|
||||||
|
{canManage && <form className="panel form-panel" onSubmit={sendTest}><div className="form-section"><div><h2>Probar configuración</h2><p className="section-copy">Envía un correo real con un archivo de prueba usando exactamente la configuración activa.</p></div><div className="form-grid"><label className="field"><span>Email de prueba</span><input type="email" value={testEmail} onChange={(event) => setTestEmail(event.target.value)} required placeholder="tu@email.com" /></label></div><div className="form-actions"><button className="button secondary" disabled={saving || !testEmail.trim()}>Enviar prueba</button></div></div></form>}
|
||||||
|
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Últimas entregas</h2><p className="section-copy">Actas PDF a Empresa/Oficina/Inspector e INF Word editable al Inspector responsable.</p></div><span className="count-pill">{items.length}</span></div>
|
||||||
|
{items.length === 0 ? <div className="inline-empty">Todavía no existen entregas documentales.</div> : <div className="table-wrap"><table><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th /></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td><strong>{item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode}</strong><small className="block-muted">{item.documentKind === 'ACT_PDF' ? 'Acta PDF inmutable' : 'INF Word editable'}</small></td><td>{recipientLabel(item)}<small className="block-muted">{item.recipientEmail ?? 'Sin email configurado'}</small></td><td><span className={`status-badge ${item.status === 'SENT' ? 'active' : item.status === 'FAILED' ? 'inactive' : 'pending'}`}>{statusLabel[item.status]}</span>{item.lastError && <small className="block-muted">{item.lastError}</small>}</td><td>{item.attempts}</td><td>{canManage && item.status !== 'SENT' && <button className="button text" type="button" disabled={saving} onClick={async () => { setSaving(true); try { await retryDocumentDeliveryF4(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar</button>}</td></tr>)}</tbody></table></div>}
|
||||||
|
</article>
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user