334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
import { useEffect, useState, useMemo } from 'react';
|
|
import { bankAccountApi } from '../api/resources';
|
|
import type { BankAccount } from '../types/api';
|
|
import ConfirmDialog from '../components/ConfirmDialog';
|
|
import { useToast } from '../components/Toast';
|
|
import SortableHeader from '../components/SortableHeader';
|
|
import { useSort } from '../hooks/useSort';
|
|
|
|
type ViewMode = 'list' | 'detail' | 'edit' | 'create';
|
|
|
|
interface FormData {
|
|
name: string;
|
|
holder: string;
|
|
iban: string;
|
|
bankName: string;
|
|
swiftBic: string;
|
|
currency: string;
|
|
isDefault: boolean;
|
|
description: string;
|
|
}
|
|
|
|
const emptyForm = (): FormData => ({
|
|
name: '', holder: '', iban: '', bankName: '', swiftBic: '',
|
|
currency: 'EUR', isDefault: false, description: '',
|
|
});
|
|
|
|
function formatIban(iban: string): string {
|
|
const cleaned = iban.replace(/\s+/g, '').toUpperCase();
|
|
return cleaned.replace(/(.{4})/g, '$1 ').trim();
|
|
}
|
|
|
|
export default function BankAccounts() {
|
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
|
const [items, setItems] = useState<BankAccount[]>([]);
|
|
const [selected, setSelected] = useState<BankAccount | null>(null);
|
|
const [form, setForm] = useState<FormData>(emptyForm());
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<BankAccount | null>(null);
|
|
const { toast } = useToast();
|
|
|
|
const { sorted, sort, toggleSort } = useSort(items, 'name');
|
|
|
|
// -- Bank name autocomplete options --
|
|
const bankNames = useMemo(() => {
|
|
const names = new Set<string>();
|
|
items.forEach((item) => {
|
|
if (item.bankName) names.add(item.bankName);
|
|
});
|
|
return [...names].sort();
|
|
}, [items]);
|
|
|
|
const loadList = () => {
|
|
setLoading(true);
|
|
bankAccountApi.getAll({ all: true })
|
|
.then((resp) => setItems(resp.data))
|
|
.catch(() => toast('Error al cargar cuentas bancarias', 'error'))
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => { loadList(); }, []);
|
|
|
|
const goToDetail = (item: BankAccount) => {
|
|
setSelected(item);
|
|
setViewMode('detail');
|
|
};
|
|
|
|
const goToCreate = () => {
|
|
setSelected(null);
|
|
setForm(emptyForm());
|
|
setViewMode('create');
|
|
};
|
|
|
|
const goToEdit = (item: BankAccount) => {
|
|
setSelected(item);
|
|
setForm({
|
|
name: item.name,
|
|
holder: item.holder,
|
|
iban: item.iban,
|
|
bankName: item.bankName ?? '',
|
|
swiftBic: item.swiftBic ?? '',
|
|
currency: item.currency,
|
|
isDefault: item.isDefault,
|
|
description: item.description ?? '',
|
|
});
|
|
setViewMode('edit');
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!form.name.trim() || !form.holder.trim() || !form.iban.trim()) {
|
|
toast('Completa los campos obligatorios', 'warning');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
name: form.name,
|
|
holder: form.holder,
|
|
iban: form.iban.replace(/\s+/g, '').toUpperCase(),
|
|
bankName: form.bankName || null,
|
|
swiftBic: form.swiftBic || null,
|
|
currency: form.currency,
|
|
isDefault: form.isDefault,
|
|
description: form.description || null,
|
|
};
|
|
|
|
if (viewMode === 'edit' && selected) {
|
|
await bankAccountApi.update(selected.id, payload as Partial<BankAccount>);
|
|
toast('Cuenta actualizada', 'success');
|
|
} else {
|
|
await bankAccountApi.create(payload as Partial<BankAccount>);
|
|
toast('Cuenta creada', 'success');
|
|
}
|
|
setViewMode('list');
|
|
loadList();
|
|
} catch {
|
|
toast('Error al guardar la cuenta', 'error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
try {
|
|
await bankAccountApi.remove(deleteTarget.id);
|
|
toast('Cuenta desactivada', 'success');
|
|
setDeleteTarget(null);
|
|
loadList();
|
|
if (viewMode === 'detail') setViewMode('list');
|
|
} catch {
|
|
toast('Error al eliminar la cuenta', 'error');
|
|
}
|
|
};
|
|
|
|
if (viewMode === 'list') {
|
|
return (
|
|
<>
|
|
<div className="page-header">
|
|
<div>
|
|
<h1>Cuentas Bancarias</h1>
|
|
<p className="text-muted">Gestiona las cuentas bancarias de la empresa</p>
|
|
</div>
|
|
<button className="btn btn-primary" onClick={goToCreate}>+ Nueva Cuenta</button>
|
|
</div>
|
|
|
|
<div className="card">
|
|
{loading ? (
|
|
<div className="loading"><div className="spinner" /> Cargando...</div>
|
|
) : items.length === 0 ? (
|
|
<div className="empty-state">
|
|
<h3>No hay cuentas bancarias</h3>
|
|
<p>Añade una cuenta bancaria para asociarla a gastos e ingresos.</p>
|
|
</div>
|
|
) : (
|
|
<div className="table-container">
|
|
<div className="table-wrapper">
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
<SortableHeader label="Nombre" sortKey="name" sort={sort} onToggle={toggleSort} />
|
|
<SortableHeader label="Banco" sortKey="bankName" sort={sort} onToggle={toggleSort} />
|
|
<SortableHeader label="IBAN" sortKey="iban" sort={sort} onToggle={toggleSort} />
|
|
<SortableHeader label="Titular" sortKey="holder" sort={sort} onToggle={toggleSort} />
|
|
<SortableHeader label="Divisa" sortKey="currency" sort={sort} onToggle={toggleSort} />
|
|
<SortableHeader label="Estado" sortKey="active" sort={sort} onToggle={toggleSort} />
|
|
<th>Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted.map((acc) => (
|
|
<tr key={acc.id}>
|
|
<td className="font-semibold">
|
|
{acc.name}
|
|
{acc.isDefault && <span className="badge badge-primary" style={{ marginLeft: 8 }}>Ppal</span>}
|
|
</td>
|
|
<td className="text-secondary">{acc.bankName ?? '-'}</td>
|
|
<td className="font-mono" style={{ fontSize: '0.85em' }}>{formatIban(acc.iban)}</td>
|
|
<td className="text-secondary">{acc.holder}</td>
|
|
<td>{acc.currency}</td>
|
|
<td>{acc.active ? <span className="badge badge-success"><span className="badge-dot badge-dot-success" /> Activa</span> : <span className="badge badge-danger"><span className="badge-dot badge-dot-danger" /> Inactiva</span>}</td>
|
|
<td>
|
|
<div className="actions">
|
|
<button className="btn btn-sm btn-secondary" onClick={() => goToDetail(acc)}>Ver</button>
|
|
<button className="btn btn-sm btn-secondary" onClick={() => goToEdit(acc)}>Editar</button>
|
|
{acc.active && (
|
|
<button className="btn btn-sm btn-danger" onClick={() => setDeleteTarget(acc)}>Desactivar</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
title="Desactivar Cuenta"
|
|
message={`¿Estás seguro de desactivar "${deleteTarget?.name}"? Los gastos e ingresos asociados no se verán afectados.`}
|
|
onConfirm={handleDelete}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (viewMode === 'detail' && selected) {
|
|
return (
|
|
<>
|
|
<div className="page-header">
|
|
<div>
|
|
<h1>{selected.name}</h1>
|
|
</div>
|
|
<div className="actions">
|
|
<button className="btn btn-secondary" onClick={() => setViewMode('list')}>Volver</button>
|
|
<button className="btn btn-primary" onClick={() => goToEdit(selected)}>Editar</button>
|
|
</div>
|
|
</div>
|
|
<div className="card">
|
|
<div className="detail-grid">
|
|
<div className="detail-field">
|
|
<span className="detail-label">Nombre</span>
|
|
<span className="detail-value">{selected.name}</span>
|
|
</div>
|
|
<div className="detail-field">
|
|
<span className="detail-label">Banco</span>
|
|
<span className="detail-value">{selected.bankName ?? '-'}</span>
|
|
</div>
|
|
<div className="detail-field">
|
|
<span className="detail-label">IBAN</span>
|
|
<span className="detail-value font-mono">{formatIban(selected.iban)}</span>
|
|
</div>
|
|
<div className="detail-field">
|
|
<span className="detail-label">SWIFT/BIC</span>
|
|
<span className="detail-value font-mono">{selected.swiftBic ?? '-'}</span>
|
|
</div>
|
|
<div className="detail-field">
|
|
<span className="detail-label">Titular</span>
|
|
<span className="detail-value">{selected.holder}</span>
|
|
</div>
|
|
<div className="detail-field">
|
|
<span className="detail-label">Divisa</span>
|
|
<span className="detail-value">{selected.currency}</span>
|
|
</div>
|
|
<div className="detail-field">
|
|
<span className="detail-label">Por Defecto</span>
|
|
<span className="detail-value">{selected.isDefault ? 'Sí' : 'No'}</span>
|
|
</div>
|
|
<div className="detail-field">
|
|
<span className="detail-label">Estado</span>
|
|
<span className="detail-value">{selected.active ? <span className="badge badge-success"><span className="badge-dot badge-dot-success" /> Activa</span> : <span className="badge badge-danger"><span className="badge-dot badge-dot-danger" /> Inactiva</span>}</span>
|
|
</div>
|
|
{selected.description && (
|
|
<div className="detail-field full-width">
|
|
<span className="detail-label">Descripción</span>
|
|
<span className="detail-value">{selected.description}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Create / Edit form
|
|
const isEdit = viewMode === 'edit';
|
|
return (
|
|
<>
|
|
<div className="page-header">
|
|
<h1>{isEdit ? `Editar: ${selected?.name}` : 'Nueva Cuenta Bancaria'}</h1>
|
|
</div>
|
|
<div className="form-sticky-bar">
|
|
<button className="btn btn-secondary" onClick={() => setViewMode(isEdit && selected ? 'detail' : 'list')}>← Cancelar</button>
|
|
<button type="submit" className="btn btn-primary" disabled={saving} form="bank-account-form">
|
|
{saving ? 'Guardando...' : (isEdit ? 'Actualizar' : 'Crear')}
|
|
</button>
|
|
</div>
|
|
<div className="card" style={{ marginTop: '24px' }}>
|
|
<form id="bank-account-form" onSubmit={handleSubmit} className="form">
|
|
<div className="form-grid">
|
|
<div className="form-group">
|
|
<label className="form-label">Nombre *</label>
|
|
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required placeholder="Ej: BBVA Empresas" />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">Titular *</label>
|
|
<input type="text" value={form.holder} onChange={(e) => setForm({ ...form, holder: e.target.value })} required placeholder="Nombre del titular" />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">IBAN *</label>
|
|
<input type="text" value={form.iban} onChange={(e) => setForm({ ...form, iban: e.target.value })} required placeholder="ES00 0000 0000 00 0000000000" />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">Banco</label>
|
|
<input type="text" list="bank-names-list" value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="Ej: Banco Santander" />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">SWIFT/BIC</label>
|
|
<input type="text" value={form.swiftBic} onChange={(e) => setForm({ ...form, swiftBic: e.target.value })} placeholder="Ej: BSCHESMM" />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">Divisa</label>
|
|
<select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}>
|
|
<option value="EUR">EUR</option>
|
|
<option value="USD">USD</option>
|
|
<option value="GBP">GBP</option>
|
|
<option value="CHF">CHF</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label"> </label>
|
|
<label className="checkbox-label">
|
|
<input type="checkbox" checked={form.isDefault} onChange={(e) => setForm({ ...form, isDefault: e.target.checked })} />
|
|
Cuenta por defecto
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="form-group full-width">
|
|
<label className="form-label">Descripción</label>
|
|
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Notas adicionales..." style={{ minHeight: '80px' }} />
|
|
</div>
|
|
|
|
<datalist id="bank-names-list">{bankNames.map(name => <option key={name} value={name} />)}</datalist>
|
|
</form>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|