feat(pos/workshop): add lightweight workshop/taller module
- Add DB migration v4.4_workshop.sql (sale_id, service_catalog, reserved_quantity, SO_RESERVE/SO_RELEASE operation types). - Extend service_order_engine with inventory reservation, release, convert-to-sale, mechanic assignment, and service catalog CRUD. - Extend service_order_bp with /reserve, /convert-to-sale, /assign-mechanic, and /service-catalog endpoints. - Create workshop Kanban UI: workshop.html, workshop.js, workshop.css. - Add /pos/workshop route and sidebar navigation (sidebar.js + inline templates). - Add 11 unit tests with mocked cursors. - Update FASES_IMPLEMENTADAS.md with FASE 9 documentation. Tests: 92 passing (61 console + 20 Facturapi + 11 workshop).
This commit is contained in:
221
pos/static/css/workshop.css
Normal file
221
pos/static/css/workshop.css
Normal file
@@ -0,0 +1,221 @@
|
||||
:root {
|
||||
--kanban-column-width: 280px;
|
||||
--kanban-card-bg: var(--glass-bg-strong);
|
||||
--kanban-card-border: var(--glass-border);
|
||||
}
|
||||
|
||||
.page-header__subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-4);
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
flex: 0 0 var(--kanban-column-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
.kanban-column__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--glass-bg-strong);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.kanban-column__count {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.kanban-column__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.kanban-card {
|
||||
background: var(--kanban-card-bg);
|
||||
border: 1px solid var(--kanban-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.kanban-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.kanban-card__id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.kanban-card__priority {
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-transform: uppercase;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.kanban-card__priority--urgent { background: var(--color-error); color: #fff; }
|
||||
.kanban-card__priority--high { background: var(--color-warn); color: #000; }
|
||||
.kanban-card__priority--normal { background: var(--color-info); color: #fff; }
|
||||
|
||||
.kanban-card__customer {
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.kanban-card__vehicle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.kanban-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.kanban-card__mechanic {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* Detail modal */
|
||||
.so-detail {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.so-detail__section {
|
||||
background: var(--glass-bg-strong);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.so-detail__section h3 {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.so-detail__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.so-detail__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.so-detail__label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.so-detail__value {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.so-detail__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Tables inside modal */
|
||||
.data-table--compact {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.data-table--compact th,
|
||||
.data-table--compact td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.data-table--compact th {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge--pending { background: var(--color-warn); color: #000; }
|
||||
.status-badge--reserved { background: var(--color-info); color: #fff; }
|
||||
.status-badge--installed { background: var(--color-success); color: #000; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.kanban-board {
|
||||
flex-direction: column;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
flex: 1 1 auto;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ window.renderSidebar = function(modulesOverride) {
|
||||
].filter(Boolean)},
|
||||
{ label: _t('nav_management'), items: [
|
||||
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||||
{ name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
|
||||
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
||||
moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
|
||||
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
|
||||
|
||||
482
pos/static/js/workshop.js
Normal file
482
pos/static/js/workshop.js
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* workshop.js — Taller / Service Orders Kanban for Nexus POS
|
||||
*/
|
||||
var Workshop = (function() {
|
||||
'use strict';
|
||||
|
||||
var API = '/pos/api/service-orders';
|
||||
var token = localStorage.getItem('pos_token');
|
||||
var orders = [];
|
||||
var catalog = [];
|
||||
var customers = [];
|
||||
var vehicles = [];
|
||||
var employees = [];
|
||||
var currentOrderId = null;
|
||||
|
||||
var COLUMNS = [
|
||||
{key: 'received', label: 'Recibido'},
|
||||
{key: 'diagnosis', label: 'Diagnóstico'},
|
||||
{key: 'waiting_parts', label: 'Espera refacciones'},
|
||||
{key: 'repair', label: 'En reparación'},
|
||||
{key: 'quality_check', label: 'Control calidad'},
|
||||
{key: 'ready', label: 'Listo'},
|
||||
{key: 'delivered', label: 'Entregado'},
|
||||
];
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + token
|
||||
};
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
if (n == null) return '0';
|
||||
return parseFloat(n).toLocaleString('es-MX');
|
||||
}
|
||||
|
||||
function fmtMoney(n) {
|
||||
if (n == null) return '$0.00';
|
||||
return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '—';
|
||||
var dt = new Date(d);
|
||||
return dt.toLocaleDateString('es-MX', {day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
var el = document.createElement('div');
|
||||
el.textContent = s;
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function api(method, url, body) {
|
||||
var opts = {method: method, headers: headers()};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
return fetch(API + url, opts).then(function(r) {
|
||||
return r.json().then(function(data) {
|
||||
if (!r.ok) throw new Error(data.error || r.statusText);
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Init ───
|
||||
|
||||
function init() {
|
||||
loadSummary();
|
||||
loadOrders();
|
||||
loadCatalog();
|
||||
loadReferenceData();
|
||||
}
|
||||
|
||||
// ─── Summary / Kanban ───
|
||||
|
||||
function loadSummary() {
|
||||
fetch(API + '/kanban/summary', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
document.getElementById('statReceived').textContent = fmt(d.received || 0);
|
||||
document.getElementById('statRepair').textContent = fmt((d.repair || 0) + (d.diagnosis || 0) + (d.waiting_parts || 0) + (d.quality_check || 0));
|
||||
document.getElementById('statReady').textContent = fmt(d.ready || 0);
|
||||
document.getElementById('statOverdue').textContent = fmt(d.overdue || 0);
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function loadOrders() {
|
||||
fetch(API + '?per_page=200', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
orders = d.data || [];
|
||||
renderKanban();
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.error(e);
|
||||
document.getElementById('kanbanBoard').innerHTML = '<div class="empty-state"><div class="empty-state__text">Error cargando órdenes</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderKanban() {
|
||||
var board = document.getElementById('kanbanBoard');
|
||||
board.innerHTML = '';
|
||||
COLUMNS.forEach(function(col) {
|
||||
var colOrders = orders.filter(function(o) { return o.status === col.key; });
|
||||
var colEl = document.createElement('div');
|
||||
colEl.className = 'kanban-column';
|
||||
colEl.innerHTML =
|
||||
'<div class="kanban-column__header">' +
|
||||
' <span>' + esc(col.label) + '</span>' +
|
||||
' <span class="kanban-column__count">' + colOrders.length + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="kanban-column__body" id="col-' + col.key + '"></div>';
|
||||
board.appendChild(colEl);
|
||||
|
||||
var body = colEl.querySelector('.kanban-column__body');
|
||||
if (!colOrders.length) {
|
||||
body.innerHTML = '<div class="empty-state__text" style="text-align:center;padding:var(--space-4);color:var(--color-text-muted);">Sin órdenes</div>';
|
||||
} else {
|
||||
colOrders.forEach(function(o) {
|
||||
body.appendChild(renderCard(o));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderCard(o) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'kanban-card';
|
||||
card.onclick = function() { openDetail(o.id); };
|
||||
card.innerHTML =
|
||||
'<div class="kanban-card__header">' +
|
||||
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
|
||||
' <span class="kanban-card__priority kanban-card__priority--' + esc(o.priority) + '">' + esc(o.priority) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="kanban-card__customer">' + esc(o.customer_name || 'Cliente general') + '</div>' +
|
||||
'<div class="kanban-card__vehicle">' + esc(o.vehicle_plate || 'Sin vehículo') + '</div>' +
|
||||
'<div class="kanban-card__meta">' +
|
||||
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
|
||||
' <span>' + fmtMoney(o.estimated_cost) + '</span>' +
|
||||
'</div>';
|
||||
return card;
|
||||
}
|
||||
|
||||
// ─── Detail modal ───
|
||||
|
||||
function openDetail(id) {
|
||||
currentOrderId = id;
|
||||
fetch(API + '/' + id, {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(o) {
|
||||
document.getElementById('detailTitle').textContent = 'Orden ' + esc(o.order_number);
|
||||
renderDetailBody(o);
|
||||
document.getElementById('detailModal').style.display = 'flex';
|
||||
})
|
||||
.catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function renderDetailBody(o) {
|
||||
var html =
|
||||
'<div class="so-detail">' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Información general</h3>' +
|
||||
' <div class="so-detail__grid">' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Estado</span><span class="so-detail__value">' + esc(o.status) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
|
||||
' </div>' +
|
||||
' <div style="margin-top:var(--space-3);"><span class="so-detail__label">Notas recepción</span><p>' + esc(o.reception_notes || '—') + '</p></div>' +
|
||||
' </div>' +
|
||||
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Refacciones</h3>' +
|
||||
' <table class="data-table--compact"><thead><tr><th>Concepto</th><th>Cant.</th><th>Precio</th><th>Estado</th><th></th></tr></thead><tbody>' +
|
||||
(o.items || []).map(function(it) {
|
||||
return '<tr>' +
|
||||
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
|
||||
'<td>' + fmt(it.quantity) + '</td>' +
|
||||
'<td>' + fmtMoney(it.unit_price) + '</td>' +
|
||||
'<td><span class="status-badge status-badge--' + (it.reserved_quantity >= it.quantity ? 'reserved' : it.status) + '">' + (it.reserved_quantity >= it.quantity ? 'Reservado' : esc(it.status)) + '</span></td>' +
|
||||
'<td>' + (it.reserved_quantity < it.quantity && it.status !== 'cancelled' ? '<button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button>' : '') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody></table>' +
|
||||
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
||||
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/numero" style="flex:1;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addItemPlaceholder()">Agregar</button>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Mano de obra</h3>' +
|
||||
' <table class="data-table--compact"><thead><tr><th>Concepto</th><th>Horas</th><th>Precio/hr</th><th>Total</th><th>Estado</th></tr></thead><tbody>' +
|
||||
(o.labor || []).map(function(l) {
|
||||
return '<tr>' +
|
||||
'<td>' + esc(l.description) + '</td>' +
|
||||
'<td>' + fmt(l.hours) + '</td>' +
|
||||
'<td>' + fmtMoney(l.hourly_rate) + '</td>' +
|
||||
'<td>' + fmtMoney(l.total_cost) + '</td>' +
|
||||
'<td><span class="status-badge status-badge--' + l.status + '">' + esc(l.status) + '</span></td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody></table>' +
|
||||
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
||||
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
|
||||
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;" />' +
|
||||
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
|
||||
' <input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Cambiar estado</h3>' +
|
||||
' <div class="so-detail__actions">' +
|
||||
' <select class="form-input" id="statusSelect" style="width:auto;">' +
|
||||
COLUMNS.map(function(c) { return '<option value="' + c.key + '"' + (c.key === o.status ? ' selected' : '') + '>' + c.label + '</option>'; }).join('') +
|
||||
' </select>' +
|
||||
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
'</div>';
|
||||
|
||||
document.getElementById('detailBody').innerHTML = html;
|
||||
|
||||
// Populate labor catalog select
|
||||
var sel = document.getElementById('laborCatalogSelect');
|
||||
if (sel) {
|
||||
catalog.forEach(function(c) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = JSON.stringify(c);
|
||||
opt.textContent = c.name + ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.onchange = function() {
|
||||
if (!sel.value) return;
|
||||
var c = JSON.parse(sel.value);
|
||||
document.getElementById('laborDesc').value = c.name;
|
||||
document.getElementById('laborHours').value = c.suggested_hours;
|
||||
document.getElementById('laborRate').value = c.suggested_rate;
|
||||
};
|
||||
}
|
||||
|
||||
// Footer actions
|
||||
var footer = document.getElementById('detailFooter');
|
||||
footer.innerHTML =
|
||||
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
||||
(o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
|
||||
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
currentOrderId = null;
|
||||
}
|
||||
|
||||
// ─── Actions ───
|
||||
|
||||
function changeStatus() {
|
||||
if (!currentOrderId) return;
|
||||
var newStatus = document.getElementById('statusSelect').value;
|
||||
api('PUT', '/' + currentOrderId + '/status', {status: newStatus})
|
||||
.then(function() {
|
||||
closeDetailModal();
|
||||
loadSummary();
|
||||
loadOrders();
|
||||
})
|
||||
.catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function reserveItem(itemId) {
|
||||
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
|
||||
.then(function() {
|
||||
alert('Refacción reservada');
|
||||
openDetail(currentOrderId);
|
||||
loadSummary();
|
||||
})
|
||||
.catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function addItemPlaceholder() {
|
||||
var name = document.getElementById('newItemSearch').value.trim();
|
||||
if (!name) return;
|
||||
api('POST', '/' + currentOrderId + '/items', {
|
||||
name: name,
|
||||
quantity: 1,
|
||||
unit_price: 0,
|
||||
status: 'pending'
|
||||
}).then(function() {
|
||||
openDetail(currentOrderId);
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function addLabor() {
|
||||
var desc = document.getElementById('laborDesc').value.trim();
|
||||
var hours = parseFloat(document.getElementById('laborHours').value) || 0;
|
||||
var rate = parseFloat(document.getElementById('laborRate').value) || 0;
|
||||
if (!desc) return alert('Escribe una descripción');
|
||||
api('POST', '/' + currentOrderId + '/labor', {
|
||||
description: desc,
|
||||
hours: hours,
|
||||
hourly_rate: rate,
|
||||
status: 'pending'
|
||||
}).then(function() {
|
||||
document.getElementById('laborDesc').value = '';
|
||||
document.getElementById('laborHours').value = '';
|
||||
document.getElementById('laborRate').value = '';
|
||||
openDetail(currentOrderId);
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function convertToSale() {
|
||||
if (!currentOrderId) return;
|
||||
if (!confirm('¿Convertir esta orden en una venta? Se descontarán las refacciones reservadas del inventario.')) return;
|
||||
api('POST', '/' + currentOrderId + '/convert-to-sale', {
|
||||
payment_method: 'efectivo',
|
||||
sale_type: 'cash'
|
||||
}).then(function(r) {
|
||||
alert('Venta creada: #' + r.sale_id + ' Total: ' + fmtMoney(r.total));
|
||||
closeDetailModal();
|
||||
loadSummary();
|
||||
loadOrders();
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
// ─── New order ───
|
||||
|
||||
function openNewOrderModal() {
|
||||
populateSelect('noCustomer', customers, function(c) { return {value: c.id, text: c.name + ' (' + (c.phone || '') + ')'}; });
|
||||
populateSelect('noVehicle', vehicles, function(v) { return {value: v.id, text: v.plate + ' ' + v.make + ' ' + v.model}; });
|
||||
populateSelect('noMechanic', employees, function(e) { return {value: e.id, text: e.name}; });
|
||||
document.getElementById('newOrderModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeNewOrderModal() {
|
||||
document.getElementById('newOrderModal').style.display = 'none';
|
||||
document.getElementById('newOrderForm').reset();
|
||||
}
|
||||
|
||||
function submitNewOrder() {
|
||||
var customerId = document.getElementById('noCustomer').value;
|
||||
if (!customerId) return alert('Selecciona un cliente');
|
||||
api('POST', '', {
|
||||
customer_id: parseInt(customerId, 10),
|
||||
vehicle_id: parseInt(document.getElementById('noVehicle').value, 10) || null,
|
||||
employee_id: parseInt(document.getElementById('noMechanic').value, 10) || null,
|
||||
priority: document.getElementById('noPriority').value,
|
||||
estimated_completion: document.getElementById('noEstimatedCompletion').value || null,
|
||||
mileage_in: parseInt(document.getElementById('noMileage').value, 10) || null,
|
||||
reception_notes: document.getElementById('noNotes').value
|
||||
}).then(function() {
|
||||
closeNewOrderModal();
|
||||
loadSummary();
|
||||
loadOrders();
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
// ─── Catalog ───
|
||||
|
||||
function openCatalogModal() {
|
||||
document.getElementById('catalogModal').style.display = 'flex';
|
||||
renderCatalog();
|
||||
}
|
||||
|
||||
function closeCatalogModal() {
|
||||
document.getElementById('catalogModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function loadCatalog() {
|
||||
fetch(API + '/service-catalog?active_only=true', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
catalog = d.data || [];
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function renderCatalog() {
|
||||
var body = document.getElementById('catalogBody');
|
||||
if (!catalog.length) {
|
||||
body.innerHTML = '<tr><td colspan="5" style="text-align:center;">Sin conceptos</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = catalog.map(function(c) {
|
||||
return '<tr>' +
|
||||
'<td>' + esc(c.name) + (c.description ? '<br><small>' + esc(c.description) + '</small>' : '') + '</td>' +
|
||||
'<td>' + fmt(c.suggested_hours) + '</td>' +
|
||||
'<td>' + fmtMoney(c.suggested_rate) + '</td>' +
|
||||
'<td>' + fmtMoney(c.suggested_hours * c.suggested_rate) + '</td>' +
|
||||
'<td><button class="btn btn--sm btn--ghost" onclick="Workshop.deleteCatalogItem(' + c.id + ')">Desactivar</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addCatalogItem() {
|
||||
var name = document.getElementById('catName').value.trim();
|
||||
if (!name) return alert('Escribe un nombre');
|
||||
api('POST', '/service-catalog', {
|
||||
name: name,
|
||||
description: document.getElementById('catDesc').value,
|
||||
suggested_hours: parseFloat(document.getElementById('catHours').value) || 0,
|
||||
suggested_rate: parseFloat(document.getElementById('catRate').value) || 0
|
||||
}).then(function() {
|
||||
document.getElementById('catName').value = '';
|
||||
document.getElementById('catDesc').value = '';
|
||||
document.getElementById('catHours').value = '';
|
||||
document.getElementById('catRate').value = '';
|
||||
loadCatalog();
|
||||
setTimeout(renderCatalog, 200);
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function deleteCatalogItem(id) {
|
||||
if (!confirm('¿Desactivar este concepto?')) return;
|
||||
api('DELETE', '/service-catalog/' + id, {})
|
||||
.then(function() {
|
||||
loadCatalog();
|
||||
setTimeout(renderCatalog, 200);
|
||||
})
|
||||
.catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
// ─── Reference data ───
|
||||
|
||||
function loadReferenceData() {
|
||||
// Customers
|
||||
fetch('/pos/api/customers?per_page=500', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { customers = (d.data || d.customers || []); })
|
||||
.catch(function() {});
|
||||
// Vehicles
|
||||
fetch('/pos/api/fleet/vehicles?per_page=500', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { vehicles = (d.data || []); })
|
||||
.catch(function() { vehicles = []; });
|
||||
// Employees
|
||||
fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { employees = (d.data || d.employees || []); })
|
||||
.catch(function() { employees = []; });
|
||||
}
|
||||
|
||||
function populateSelect(id, items, mapper) {
|
||||
var sel = document.getElementById(id);
|
||||
if (!sel) return;
|
||||
sel.innerHTML = id === 'noCustomer' ? '' : '<option value="">—</option>';
|
||||
items.forEach(function(it) {
|
||||
var opt = mapper(it);
|
||||
var el = document.createElement('option');
|
||||
el.value = opt.value;
|
||||
el.textContent = opt.text;
|
||||
sel.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Public API ───
|
||||
|
||||
return {
|
||||
init: init,
|
||||
openDetail: openDetail,
|
||||
closeDetailModal: closeDetailModal,
|
||||
changeStatus: changeStatus,
|
||||
reserveItem: reserveItem,
|
||||
addItemPlaceholder: addItemPlaceholder,
|
||||
addLabor: addLabor,
|
||||
convertToSale: convertToSale,
|
||||
openNewOrderModal: openNewOrderModal,
|
||||
closeNewOrderModal: closeNewOrderModal,
|
||||
submitNewOrder: submitNewOrder,
|
||||
openCatalogModal: openCatalogModal,
|
||||
closeCatalogModal: closeCatalogModal,
|
||||
addCatalogItem: addCatalogItem,
|
||||
deleteCatalogItem: deleteCatalogItem,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', Workshop.init);
|
||||
Reference in New Issue
Block a user