F-201 F-202 F-203: POS cash close report + email + PIN admin

F-201: GET /pos/reports/cash-close/:id with financial summary, sales
  by state, payments breakdown, items sold. Extended /pos/sessions/:id.

F-202: Cash close email sent on session close to smtpReportEmail
  (best-effort). smtpReportEmail field added to admin SMTP settings.

F-203: Admin POS terminal config: selfpayMode, closeSessionRequiresPin,
  closeSessionPin (4-6 digits) with dedicated settings section.
This commit is contained in:
chattie
2026-08-23 09:24:37 +02:00
parent 4b3a506166
commit 18a518e58b
100 changed files with 1704 additions and 284 deletions

View File

@@ -211,6 +211,33 @@ export default function PosAdminPage() {
}
};
// F-203: save selfpay + PIN settings via PATCH /pos/admin/terminals/:id
const saveSecuritySettings = async () => {
if (!configuring) return;
setSavingSecurity(true);
setSecurityMessage('');
try {
const settings: Record<string, unknown> = {
selfpayMode,
closeSessionRequiresPin: closePinRequired,
};
if (closePinValue) settings.closeSessionPin = closePinValue;
await api.patch(`/api/pos/admin/terminals/${configuring.id}`, { settings });
setSecurityMessage('Configuración de cierre guardada');
setTerminals(prev =>
prev.map(t =>
t.id === configuring.id
? { ...t, settings: { ...t.settings, ...settings } }
: t,
),
);
} catch (err) {
setSecurityMessage('Error al guardar: ' + (err instanceof Error ? err.message : 'Error'));
} finally {
setSavingSecurity(false);
}
};
const createPaymentMethod = async (event: React.FormEvent) => {
event.preventDefault();
setSavingMethod(true);
@@ -600,16 +627,55 @@ export default function PosAdminPage() {
</label>
))}
</div>
<div className="mt-5 flex items-center gap-4">
<button
type="button"
onClick={() => void saveTouchConfig()}
disabled={savingTouch}
className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50"
>
{savingTouch ? 'Guardando…' : 'Guardar terminal'}
</button>
{touchMessage && <p className="text-sm">{touchMessage}</p>}
{/* F-203: cash close security settings */}
<div className="mt-6 border-t pt-4">
<h3 className="mb-3 text-sm font-bold text-gray-900">Seguridad de cierre de caja</h3>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-xl bg-gray-50 p-4 text-sm font-medium">
<input
type="checkbox"
checked={selfpayMode}
onChange={(e) => setSelfpayMode(e.target.checked)}
className="h-5 w-5 accent-[#2D6A4F]"
/>
Modo autopago (oculta botón de cierre)
</label>
<label className="flex items-center gap-3 rounded-xl bg-gray-50 p-4 text-sm font-medium">
<input
type="checkbox"
checked={closePinRequired}
onChange={(e) => setClosePinRequired(e.target.checked)}
className="h-5 w-5 accent-[#2D6A4F]"
/>
Requerir PIN para cerrar caja
</label>
</div>
<div className="mt-3">
<label className="mb-1 block text-sm font-medium text-gray-700">
PIN de cajero {closePinRequired && <span className="text-red-500">*</span>}
</label>
<input
type="password"
inputMode="numeric"
maxLength={6}
value={closePinValue}
onChange={(e) => setClosePinValue(e.target.value)}
placeholder="4-6 dígitos"
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm focus:border-[#2D6A4F] focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">Este PIN se requiere para autorizar el cierre de caja.</p>
</div>
<div className="mt-3 flex items-center gap-4">
<button
type="button"
onClick={() => void saveSecuritySettings()}
disabled={savingSecurity}
className="rounded-xl bg-[#2D6A4F] px-5 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
{savingSecurity ? 'Guardando…' : 'Guardar seguridad'}
</button>
{securityMessage && <p className="text-sm">{securityMessage}</p>}
</div>
</div>
</section>
)}
@@ -817,7 +883,7 @@ function Field({
{label}
<input
type={type}
value={value}
value={value ?? ""}
onChange={(event) => onChange(event.target.value)}
required={required}
min={min}

View File

@@ -198,6 +198,11 @@ export default function SettingsPage() {
{field('smtpUser', 'Usuario / cuenta de correo', { type: 'email', placeholder: 'info@mercadodevida.es' })}
{field('smtpPass', 'Contraseña SMTP', { type: 'password', placeholder: form?.smtpPassConfigured ? 'Contraseña configurada (escribe para reemplazar)' : 'Contraseña del buzón' })}
{field('smtpFrom', 'Remitente', { type: 'email', placeholder: 'info@mercadodevida.es' })}
<div className="border-t border-gray-200 pt-4 mt-2">
<h3 className="mb-3 text-sm font-semibold text-gray-800">Reportes automáticos</h3>
{field('smtpReportEmail', 'Email destino de reportes', { type: 'email', placeholder: 'direccion@ejemplo.com' })}
<p className="text-xs text-gray-400 -mt-2">Recibirás el reporte de cierre de caja por email al cerrar cada sesión.</p>
</div>
</div>
</>
)}