feat(customers): show vehicles and delete customer
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- get_customer now returns fleet_vehicles assigned to the customer.

- Added DELETE /pos/api/customers/<id> soft-delete (is_active=false) with customers.delete permission.

- Added Vehicles section in customer detail panel.

- Added Eliminar button in quick actions (owner/admin/customers.delete).
This commit is contained in:
2026-06-30 19:58:45 +00:00
parent 66b8fe3d69
commit 073c97406b
4 changed files with 114 additions and 3 deletions

View File

@@ -165,6 +165,21 @@ def get_customer(customer_id):
float(customer['credit_limit']) - float(customer['credit_balance']), 2
)
# Fleet vehicles assigned to this customer
cur.execute("""
SELECT id, plate, vin, make, model, year, current_mileage, color, owner_name, is_active, created_at
FROM fleet_vehicles
WHERE customer_id = %s
ORDER BY is_active DESC, created_at DESC
""", (customer_id,))
customer['fleet_vehicles'] = []
for r in cur.fetchall():
customer['fleet_vehicles'].append({
'id': r[0], 'plate': r[1], 'vin': r[2], 'make': r[3], 'model': r[4],
'year': r[5], 'current_mileage': r[6], 'color': r[7], 'owner_name': r[8],
'is_active': r[9], 'created_at': str(r[10]) if r[10] else None,
})
cur.close()
conn.close()
return jsonify(customer)
@@ -303,6 +318,26 @@ def update_customer(customer_id):
return jsonify({'message': 'Customer updated'})
@customers_bp.route('/<int:customer_id>', methods=['DELETE'])
@require_auth('customers.delete')
def delete_customer(customer_id):
"""Soft-delete (deactivate) a customer."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Customer not found'}), 404
cur.execute("UPDATE customers SET is_active = false WHERE id = %s", (customer_id,))
log_action(conn, 'CUSTOMER_DEACTIVATE', 'customer', customer_id)
conn.commit()
cur.close()
conn.close()
return jsonify({'message': 'Cliente eliminado'})
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
@require_auth('customers.view')
def customer_statement(customer_id):

View File

@@ -1209,6 +1209,16 @@
color: #000;
}
.action-btn--danger {
color: var(--color-error);
border-color: var(--color-error);
}
.action-btn--danger:hover {
background-color: var(--color-error);
color: #fff;
}
.action-btn__icon {
width: 20px;
height: 20px;

View File

@@ -11,6 +11,11 @@ const Customers = (() => {
let currentCustomer = null;
let searchTimeout = null;
const user = window.POS_USER || {};
const userRole = (user.role || '').toLowerCase();
const userPerms = user.permissions || [];
const canDeleteCustomer = userRole === 'owner' || userRole === 'admin' || userPerms.includes('customers.delete');
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
@@ -295,6 +300,28 @@ const Customers = (() => {
const discountEl = document.getElementById('detailMaxDiscount');
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
// Vehicles
const vehiclesEl = document.getElementById('detailVehicles');
if (vehiclesEl) {
const vehicles = c.fleet_vehicles || [];
if (vehicles.length === 0) {
vehiclesEl.innerHTML = '<span style="color:var(--color-text-muted);">Sin veh&iacute;culos registrados</span>';
} else {
vehiclesEl.innerHTML = vehicles.map(v => {
const title = [v.year, v.make, v.model].filter(Boolean).join(' ');
const subtitle = [v.plate, v.vin, v.color].filter(Boolean).join(' · ');
return `<div class="vehicle-row" style="padding:var(--space-2);border:1px solid var(--color-border);border-radius:var(--radius-md);">
<div style="font-weight:600;">${title || 'Veh&iacute;culo'}</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${subtitle}</div>
</div>`;
}).join('');
}
}
// Delete button visibility
const btnDelete = document.getElementById('btnDeleteCustomer');
if (btnDelete) btnDelete.style.display = canDeleteCustomer ? 'inline-flex' : 'none';
// Re-wire action buttons after detail panel is visible
wireActionButtons();
@@ -396,7 +423,7 @@ const Customers = (() => {
// Wire action buttons in detail panel
function wireActionButtons() {
const btns = document.querySelectorAll('.quick-actions .action-btn');
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial, Eliminar
if (btns.length >= 1) btns[0].onclick = () => {
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
};
@@ -405,8 +432,29 @@ const Customers = (() => {
if (btns.length >= 4) btns[3].onclick = () => {
if (currentCustomer) showCustomerHistory(currentCustomer.id);
};
const btnDelete = document.getElementById('btnDeleteCustomer');
if (btnDelete) btnDelete.onclick = () => deleteCustomer();
}
async function deleteCustomer() {
if (!currentCustomer) return;
if (!canDeleteCustomer) {
alert('No tienes permiso para eliminar clientes');
return;
}
if (!confirm(`¿Eliminar al cliente "${currentCustomer.name}"? Se marcará como inactivo.`)) return;
try {
await api(`/pos/api/customers/${currentCustomer.id}`, { method: 'DELETE' });
alert('Cliente eliminado');
currentCustomer = null;
closeDetail();
loadCustomers(currentPage);
} catch (e) {
alert('Error: ' + e.message);
}
}
window.deleteCustomer = deleteCustomer;
async function showCustomerHistory(customerId) {
try {
const res = await api(`/pos/api/customers/${customerId}/purchases`);
@@ -906,6 +954,7 @@ const Customers = (() => {
showStatement, closeStatement,
showPaymentModal, closePayment, recordPayment,
showCustomerHistory, closeCustomerHistoryModal,
deleteCustomer,
};
// Register Cmd+K items

View File

@@ -15,7 +15,7 @@
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/customers.css"></head>
<link rel="stylesheet" href="/pos/static/css/customers.css?v=34"></head>
<body>
@@ -451,6 +451,14 @@
</div>
</div>
<!-- Vehicles -->
<div class="detail-section">
<div class="detail-section__title">Veh&iacute;culos</div>
<div id="detailVehicles" style="display:flex;flex-direction:column;gap:var(--space-2);">
<span style="color:var(--color-text-muted);">Sin veh&iacute;culos registrados</span>
</div>
</div>
<!-- Quick Actions -->
<div class="detail-section">
<div class="detail-section__title">Acciones Rápidas</div>
@@ -489,6 +497,15 @@
</span>
Historial
</button>
<button class="action-btn action-btn--danger" id="btnDeleteCustomer" onclick="deleteCustomer()">
<span class="action-btn__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<polyline points="3 6 5 6 13 6"/><path d="M5 6v7a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V6"/>
<line x1="7" y1="3" x2="9" y2="3"/><line x1="4" y1="3" x2="14" y2="3"/>
</svg>
</span>
Eliminar
</button>
</div>
</div>
@@ -639,7 +656,7 @@
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/virtual-scroll.js" defer></script>
<script src="/pos/static/js/customers.js?v=33" defer></script>
<script src="/pos/static/js/customers.js?v=34" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>