Initial commit: proyecto ContabilidadSaPolar completo
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user