feat(customers): show vehicles and delete customer
- 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:
@@ -165,6 +165,21 @@ def get_customer(customer_id):
|
|||||||
float(customer['credit_limit']) - float(customer['credit_balance']), 2
|
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()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify(customer)
|
return jsonify(customer)
|
||||||
@@ -303,6 +318,26 @@ def update_customer(customer_id):
|
|||||||
return jsonify({'message': 'Customer updated'})
|
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'])
|
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
|
||||||
@require_auth('customers.view')
|
@require_auth('customers.view')
|
||||||
def customer_statement(customer_id):
|
def customer_statement(customer_id):
|
||||||
|
|||||||
@@ -1209,6 +1209,16 @@
|
|||||||
color: #000;
|
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 {
|
.action-btn__icon {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ const Customers = (() => {
|
|||||||
let currentCustomer = null;
|
let currentCustomer = null;
|
||||||
let searchTimeout = 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', {
|
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
|
||||||
minimumFractionDigits: 2, maximumFractionDigits: 2
|
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||||
});
|
});
|
||||||
@@ -295,6 +300,28 @@ const Customers = (() => {
|
|||||||
const discountEl = document.getElementById('detailMaxDiscount');
|
const discountEl = document.getElementById('detailMaxDiscount');
|
||||||
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
|
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í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í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
|
// Re-wire action buttons after detail panel is visible
|
||||||
wireActionButtons();
|
wireActionButtons();
|
||||||
|
|
||||||
@@ -396,7 +423,7 @@ const Customers = (() => {
|
|||||||
// Wire action buttons in detail panel
|
// Wire action buttons in detail panel
|
||||||
function wireActionButtons() {
|
function wireActionButtons() {
|
||||||
const btns = document.querySelectorAll('.quick-actions .action-btn');
|
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 (btns.length >= 1) btns[0].onclick = () => {
|
||||||
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
|
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
|
||||||
};
|
};
|
||||||
@@ -405,8 +432,29 @@ const Customers = (() => {
|
|||||||
if (btns.length >= 4) btns[3].onclick = () => {
|
if (btns.length >= 4) btns[3].onclick = () => {
|
||||||
if (currentCustomer) showCustomerHistory(currentCustomer.id);
|
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) {
|
async function showCustomerHistory(customerId) {
|
||||||
try {
|
try {
|
||||||
const res = await api(`/pos/api/customers/${customerId}/purchases`);
|
const res = await api(`/pos/api/customers/${customerId}/purchases`);
|
||||||
@@ -906,6 +954,7 @@ const Customers = (() => {
|
|||||||
showStatement, closeStatement,
|
showStatement, closeStatement,
|
||||||
showPaymentModal, closePayment, recordPayment,
|
showPaymentModal, closePayment, recordPayment,
|
||||||
showCustomerHistory, closeCustomerHistoryModal,
|
showCustomerHistory, closeCustomerHistoryModal,
|
||||||
|
deleteCustomer,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register Cmd+K items
|
// Register Cmd+K items
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<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>
|
<body>
|
||||||
|
|
||||||
@@ -451,6 +451,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Vehicles -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="detail-section__title">Vehículos</div>
|
||||||
|
<div id="detailVehicles" style="display:flex;flex-direction:column;gap:var(--space-2);">
|
||||||
|
<span style="color:var(--color-text-muted);">Sin vehículos registrados</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<div class="detail-section__title">Acciones Rápidas</div>
|
<div class="detail-section__title">Acciones Rápidas</div>
|
||||||
@@ -489,6 +497,15 @@
|
|||||||
</span>
|
</span>
|
||||||
Historial
|
Historial
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -639,7 +656,7 @@
|
|||||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
<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/sidebar.js?v=36" defer></script>
|
||||||
<script src="/pos/static/js/virtual-scroll.js" 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/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.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>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user