Autosave: 20260217-031338

This commit is contained in:
Flatlogic Bot 2026-02-17 03:13:39 +00:00
parent 520d1acb1b
commit 1bf602b5b0
7 changed files with 521 additions and 328 deletions

View File

@ -16,11 +16,11 @@ body {
left: 0;
background: linear-gradient(145deg, #2c3e50, #34495e);
color: #ecf0f1;
padding-top: 20px;
box-shadow: 2px 0 15px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
z-index: 1000;
overflow-y: auto; /* Permite el scroll vertical si el contenido es muy largo */
display: flex;
flex-direction: column;
}
.sidebar .navbar-brand {
@ -29,6 +29,12 @@ body {
font-weight: bold;
text-align: center;
font-size: 1.5rem;
flex-shrink: 0; /* Prevent brand from shrinking */
}
.menu-wrapper {
flex-grow: 1; /* Takes up all available space */
overflow-y: auto; /* Allows scrolling for menu items */
}
.sidebar .nav-link {
@ -53,10 +59,13 @@ body {
padding-left: 21px;
}
.sidebar .nav-item.mt-auto {
position: absolute;
bottom: 20px;
width: 100%;
.logout-wrapper {
flex-shrink: 0; /* Prevents the logout area from shrinking */
border-top: 1px solid #4a627a; /* Separator line */
}
.logout-wrapper .nav-link {
font-weight: bold;
}
/* Content Area Styles */

View File

@ -216,7 +216,7 @@ $navItems = [
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo isset($pageTitle) ? htmlspecialchars($pageTitle) : 'FLOOWER CRM'; ?></title>
<title><?php echo isset($pageTitle) ? htmlspecialchars($pageTitle) : 'FLOOWER ERP'; ?></title>
<meta name="description" content="CRM de seguimiento de pedidos para Floower Store.">
<meta name="keywords" content="dashboard, call center, payment validation, order tracking, crm">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
@ -230,53 +230,55 @@ $navItems = [
<i class="fas fa-bars"></i>
</button>
<div class="sidebar">
<a href="pedidos.php" class="navbar-brand"><h3>FLOOWER CRM</h3></a>
<ul class="nav flex-column">
<?php
$currentPage = basename($_SERVER['PHP_SELF']);
<a href="pedidos.php" class="navbar-brand"><h3>FLOOWER ERP</h3></a>
<div class="menu-wrapper">
<ul class="nav flex-column">
<?php
$currentPage = basename($_SERVER['PHP_SELF']);
foreach ($navItems as $key => $item):
if (in_array($userRole, $item['roles'])):
if (isset($item['submenu'])):
$isSubmenuActive = false;
foreach ($item['submenu'] as $sub_item) {
if ($currentPage == $sub_item['url']) {
$isSubmenuActive = true;
break;
foreach ($navItems as $key => $item):
if (in_array($userRole, $item['roles'])):
if (isset($item['submenu'])):
$isSubmenuActive = false;
foreach ($item['submenu'] as $sub_item) {
if ($currentPage == $sub_item['url']) {
$isSubmenuActive = true;
break;
}
}
}
?>
<li class="nav-item has-submenu <?php echo $isSubmenuActive ? 'active' : ''; ?>">
<a href="#" class="nav-link"><i class="fas <?php echo $item['icon']; ?>"></i> <?php echo $item['text']; ?></a>
<ul class="submenu <?php echo $isSubmenuActive ? 'show' : ''; ?>">
<?php foreach ($item['submenu'] as $sub_item):
if (in_array($userRole, $sub_item['roles'])):
$isActive = $currentPage == $sub_item['url'] ? 'active' : '';
?>
<li class="nav-item">
<a href="<?php echo $sub_item['url']; ?>" class="nav-link <?php echo $isActive; ?>"><i class="fas <?php echo $sub_item['icon']; ?>"></i> <?php echo $sub_item['text']; ?></a>
</li>
<?php
endif;
endforeach; ?>
</ul>
</li>
<?php else:
$isActive = $currentPage == $item['url'] ? 'active' : '';
?>
<li class="nav-item">
<a href="<?php echo $item['url']; ?>" class="nav-link <?php echo $isActive; ?>"><i class="fas <?php echo $item['icon']; ?>"></i> <?php echo $item['text']; ?></a>
</li>
<?php endif;
endif;
endforeach;
?>
<li class="nav-item mt-auto">
<a href="logout.php" class="nav-link"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a>
</li>
</ul>
?>
<li class="nav-item has-submenu <?php echo $isSubmenuActive ? 'active' : ''; ?>">
<a href="#" class="nav-link"><i class="fas <?php echo $item['icon']; ?>"></i> <?php echo $item['text']; ?></a>
<ul class="submenu <?php echo $isSubmenuActive ? 'show' : ''; ?>">
<?php foreach ($item['submenu'] as $sub_item):
if (in_array($userRole, $sub_item['roles'])):
$isActive = $currentPage == $sub_item['url'] ? 'active' : '';
?>
<li class="nav-item">
<a href="<?php echo $sub_item['url']; ?>" class="nav-link <?php echo $isActive; ?>"><i class="fas <?php echo $sub_item['icon']; ?>"></i> <?php echo $sub_item['text']; ?></a>
</li>
<?php
endif;
endforeach; ?>
</ul>
</li>
<?php else:
$isActive = $currentPage == $item['url'] ? 'active' : '';
?>
<li class="nav-item">
<a href="<?php echo $item['url']; ?>" class="nav-link <?php echo $isActive; ?>"><i class="fas <?php echo $item['icon']; ?>"></i> <?php echo $item['text']; ?></a>
</li>
<?php endif;
endif;
endforeach;
?>
</ul>
</div>
<div class="logout-wrapper">
<a href="logout.php" class="nav-link"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a>
</div>
</div>
<div class="content">
<div class="container-fluid">
<h1 class="mt-4"><?php echo isset($pageTitle) ? htmlspecialchars($pageTitle) : 'Bienvenido'; ?></h1>
<h1 class="mt-4"><?php echo isset($pageTitle) ? htmlspecialchars($pageTitle) : 'Bienvenido'; ?></h1>

View File

@ -155,6 +155,7 @@ include 'layout_header.php';
<th>Celular</th>
<th>Producto</th>
<th>Sede de Envío</th>
<th>Cantidad</th>
<th>Monto Total</th>
<th>Monto Debe</th>
<th> De Orden</th>
@ -176,6 +177,7 @@ include 'layout_header.php';
<td><?php echo htmlspecialchars($pedido['celular']); ?></td>
<td><?php echo htmlspecialchars($pedido['producto']); ?></td>
<td><?php echo htmlspecialchars($pedido['sede_envio'] ?? 'N/A'); ?></td>
<td><?php echo htmlspecialchars($pedido['cantidad'] ?? '1'); ?></td>
<td><?php echo htmlspecialchars($pedido['monto_total']); ?></td>
<td><?php echo htmlspecialchars($pedido['monto_debe']); ?></td>
<td class="editable" data-id="<?php echo $pedido['id']; ?>" data-field="codigo_rastreo"><?php echo htmlspecialchars($pedido['codigo_rastreo'] ?? 'N/A'); ?></td>

View File

@ -0,0 +1,62 @@
<?php
header('Content-Type: application/json');
require_once 'db/config.php';
$response = ['success' => false, 'message' => 'Petición inválida.'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$sede_id = isset($_POST['sede_id']) ? filter_var($_POST['sede_id'], FILTER_VALIDATE_INT) : null;
$product_id = isset($_POST['product_id']) ? filter_var($_POST['product_id'], FILTER_VALIDATE_INT) : null;
$cantidad = isset($_POST['cantidad']) ? filter_var($_POST['cantidad'], FILTER_VALIDATE_INT) : null;
if (!$sede_id || !$product_id || !$cantidad || $cantidad <= 0) {
$response['message'] = 'Datos inválidos. Por favor, complete todos los campos correctamente.';
echo json_encode($response);
exit;
}
try {
$pdo = db();
$pdo->beginTransaction();
// 1. Actualizar o insertar en stock_sedes
$sql_stock = "INSERT INTO stock_sedes (id_producto, id_sede, cantidad)
VALUES (:product_id, :sede_id, :cantidad)
ON DUPLICATE KEY UPDATE cantidad = cantidad + :cantidad";
$stmt_stock = $pdo->prepare($sql_stock);
$stmt_stock->bindParam(':product_id', $product_id, PDO::PARAM_INT);
$stmt_stock->bindParam(':sede_id', $sede_id, PDO::PARAM_INT);
$stmt_stock->bindParam(':cantidad', $cantidad, PDO::PARAM_INT);
$stmt_stock->execute();
// 2. Registrar el movimiento
$sql_movement = "INSERT INTO stock_movements (product_id, sede_id, tipo_movimiento, cantidad, origen)
VALUES (:product_id, :sede_id, 'entrada', :cantidad, 'manual')";
$stmt_movement = $pdo->prepare($sql_movement);
$stmt_movement->bindParam(':product_id', $product_id, PDO::PARAM_INT);
$stmt_movement->bindParam(':sede_id', $sede_id, PDO::PARAM_INT);
$stmt_movement->bindParam(':cantidad', $cantidad, PDO::PARAM_INT);
$stmt_movement->execute();
$pdo->commit();
$response['success'] = true;
$response['message'] = "Se han añadido {$cantidad} unidades al stock correctamente.";
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// Log del error para depuración, no mostrar al usuario final
error_log("Error en registrar_entrada_manual_api.php: " . $e->getMessage());
$response['message'] = 'Error en la base de datos. No se pudo registrar la entrada.';
}
} else {
$response['message'] = 'Método no permitido.';
}
echo json_encode($response);
?>

View File

@ -5,72 +5,70 @@ require_once 'db/config.php';
$response = ['success' => false, 'message' => 'Petición inválida.'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$sede_id = isset($_POST['sede_id']) ? trim($_POST['sede_id']) : '';
$product_id_raw = isset($_POST['product_id']) ? trim($_POST['product_id']) : '';
$quantity_to_remove = isset($_POST['quantity']) ? (int)$_POST['quantity'] : 0;
$sede_id = isset($_POST['sede_id']) ? filter_var($_POST['sede_id'], FILTER_VALIDATE_INT) : null;
$product_id = isset($_POST['product_id']) ? filter_var($_POST['product_id'], FILTER_VALIDATE_INT) : null;
$quantity = isset($_POST['quantity']) ? filter_var($_POST['quantity'], FILTER_VALIDATE_INT) : null;
// Extraer el ID numérico del producto
$product_id_parts = explode('-', $product_id_raw);
$product_id = end($product_id_parts);
if (empty($sede_id) || !is_numeric($product_id) || $quantity_to_remove <= 0) {
$response['message'] = 'Por favor, complete todos los campos correctamente.';
if (!$sede_id || !$product_id || !$quantity || $quantity <= 0) {
$response['message'] = 'Datos inválidos. Por favor, complete todos los campos correctamente.';
echo json_encode($response);
exit;
}
$pdo = db();
$pdo->beginTransaction();
try {
// 1. Obtener el stock actual y bloquear la fila para evitar concurrencia
$stmt = $pdo->prepare("SELECT quantity FROM stock_sedes WHERE sede_id = :sede_id AND product_id = :product_id FOR UPDATE");
$stmt->execute(['sede_id' => $sede_id, 'product_id' => $product_id]);
$stock = $stmt->fetch(PDO::FETCH_ASSOC);
$pdo = db();
$pdo->beginTransaction();
$current_quantity = $stock ? (int)$stock['quantity'] : 0;
// 1. Verificar stock actual
$sql_check = "SELECT cantidad FROM stock_sedes WHERE id_producto = :product_id AND id_sede = :sede_id FOR UPDATE";
$stmt_check = $pdo->prepare($sql_check);
$stmt_check->bindParam(':product_id', $product_id, PDO::PARAM_INT);
$stmt_check->bindParam(':sede_id', $sede_id, PDO::PARAM_INT);
$stmt_check->execute();
$current_stock_row = $stmt_check->fetch(PDO::FETCH_ASSOC);
if ($current_quantity < $quantity_to_remove) {
$response['message'] = "No se puede retirar más stock del disponible. Stock actual: {$current_quantity}.";
$current_stock = $current_stock_row ? (int)$current_stock_row['cantidad'] : 0;
if ($current_stock < $quantity) {
$response['message'] = "Stock insuficiente. Stock actual: {$current_stock} unidades.";
$pdo->rollBack();
echo json_encode($response);
exit;
}
// 2. Calcular la nueva cantidad
$new_quantity = $current_quantity - $quantity_to_remove;
// 2. Actualizar stock_sedes
$sql_update = "UPDATE stock_sedes SET cantidad = cantidad - :quantity WHERE id_producto = :product_id AND id_sede = :sede_id";
$stmt_update = $pdo->prepare($sql_update);
$stmt_update->bindParam(':quantity', $quantity, PDO::PARAM_INT);
$stmt_update->bindParam(':product_id', $product_id, PDO::PARAM_INT);
$stmt_update->bindParam(':sede_id', $sede_id, PDO::PARAM_INT);
$stmt_update->execute();
// 3. Actualizar la tabla de stock
$update_stmt = $pdo->prepare(
"UPDATE stock_sedes SET quantity = :new_quantity WHERE sede_id = :sede_id AND product_id = :product_id"
);
$update_stmt->execute([
'new_quantity' => $new_quantity,
'sede_id' => $sede_id,
'product_id' => $product_id
]);
// 4. (Opcional pero recomendado) Registrar el movimiento
$movement_stmt = $pdo->prepare(
"INSERT INTO stock_movements (product_id, sede_id, quantity, type, movement_date) VALUES (:product_id, :sede_id, :quantity, 'salida', NOW())"
);
$movement_stmt->execute([
'product_id' => $product_id,
'sede_id' => $sede_id,
'quantity' => $quantity_to_remove
]);
// 3. Registrar el movimiento
$sql_movement = "INSERT INTO stock_movements (product_id, sede_id, tipo_movimiento, cantidad, origen)
VALUES (:product_id, :sede_id, 'salida', :quantity, 'manual')";
$stmt_movement = $pdo->prepare($sql_movement);
$stmt_movement->bindParam(':product_id', $product_id, PDO::PARAM_INT);
$stmt_movement->bindParam(':sede_id', $sede_id, PDO::PARAM_INT);
$stmt_movement->bindParam(':quantity', $quantity, PDO::PARAM_INT);
$stmt_movement->execute();
$pdo->commit();
$response['success'] = true;
$response['message'] = "Salida manual registrada con éxito. Stock actualizado a {$new_quantity} unidades.";
$response['message'] = "Se han retirado {$quantity} unidades del stock correctamente.";
} catch (PDOException $e) {
$pdo->rollBack();
// En un entorno de producción, no deberías exponer el mensaje de error detallado.
// Considera registrar el error en un archivo de logs.
$response['message'] = 'Error en la base de datos al procesar la solicitud.'; // Mensaje genérico para el usuario
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log("Error en registrar_salida_manual_api.php: " . $e->getMessage());
$response['message'] = 'Error en la base de datos. No se pudo registrar la salida.';
}
} else {
$response['message'] = 'Método no permitido.';
}
echo json_encode($response);
?>
?>

View File

@ -1,24 +1,30 @@
<?php
$pageTitle = "Registro de Entrada por Unidad";
$pageTitle = "Registro de Entrada";
require_once 'layout_header.php';
require_once 'db/config.php';
$error_page_load = '';
// Obtener sedes para el dropdown
$sedes = [];
$products = [];
try {
$pdo = db();
// Obtener sedes
$sedes_stmt = $pdo->query("SELECT id, nombre FROM sedes ORDER BY nombre ASC");
$sedes = $sedes_stmt->fetchAll(PDO::FETCH_ASSOC);
// Obtener productos
$products_stmt = $pdo->query("SELECT id, nombre FROM products ORDER BY nombre ASC");
$products = $products_stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
$error_page_load = "Error al cargar las sedes: " . $e->getMessage();
$error_page_load = "Error al cargar datos iniciales: " . $e->getMessage();
}
?>
<div class="container mt-4">
<div class="row">
<div class="col-lg-6 mx-auto">
<div class="col-lg-8 mx-auto">
<!-- Contenedor para notificaciones (toasts) -->
<div id="notification-container" class="position-fixed top-0 end-0 p-3" style="z-index: 1100"></div>
@ -31,39 +37,88 @@ try {
<div class="card">
<div class="card-header">
<i class="fa fa-barcode"></i> Registro de Entrada por Unidad
<i class="fa fa-sign-in"></i> <?php echo $pageTitle; ?>
</div>
<div class="card-body">
<form id="scan-form" onsubmit="return false;">
<div class="mb-3">
<label for="sede" class="form-label">Sede de Destino</label>
<select class="form-select" id="sede" name="sede_id" required>
<option value="">Seleccione una sede</option>
<?php foreach ($sedes as $sede): ?>
<option value="<?php echo htmlspecialchars($sede['id']); ?>">
<?php echo htmlspecialchars($sede['nombre']); ?>
</option>
<?php endforeach; ?>
</select>
<!-- Pestañas de Navegación -->
<ul class="nav nav-tabs" id="entryTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="barcode-tab" data-bs-toggle="tab" data-bs-target="#barcode-entry" type="button" role="tab" aria-controls="barcode-entry" aria-selected="true">
<i class="fa fa-barcode"></i> Por Código de Barras
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="manual-tab" data-bs-toggle="tab" data-bs-target="#manual-entry" type="button" role="tab" aria-controls="manual-entry" aria-selected="false">
<i class="fa fa-edit"></i> Manual por Cantidad
</button>
</li>
</ul>
<!-- Contenido de las Pestañas -->
<div class="tab-content" id="entryTabsContent">
<!-- Pestaña 1: Entrada por Código de Barras -->
<div class="tab-pane fade show active" id="barcode-entry" role="tabpanel" aria-labelledby="barcode-tab">
<form id="scan-form" onsubmit="return false;" class="mt-3">
<div class="mb-3">
<label for="sede-barcode" class="form-label">Sede de Destino</label>
<select class="form-select" id="sede-barcode" name="sede_id" required>
<option value="">Seleccione una sede</option>
<?php foreach ($sedes as $sede): ?>
<option value="<?php echo htmlspecialchars($sede['id']); ?>"><?php echo htmlspecialchars($sede['nombre']); ?></option>
<?php endforeach; ?>
</select>
</div>
<hr>
<div class="mb-3">
<label for="barcode-input" class="form-label">Esperando código de barras...</label>
<div class="input-group">
<input type="text" class="form-control form-control-lg" id="barcode-input" placeholder="Escanee el producto aquí" autofocus>
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#camera-modal">
<i class="fa fa-camera"></i>
</button>
</div>
</div>
<div id="scanned-product-info" class="mt-3" style="display: none;">
<p><strong>Producto escaneado:</strong> <span id="scanned-code"></span></p>
<div class="d-grid gap-2">
<button type="button" id="accept-btn" class="btn btn-success"> <i class="fa fa-check-circle"></i> Aceptar y Registrar</button>
<button type="button" id="cancel-btn" class="btn btn-danger"> <i class="fa fa-times-circle"></i> Cancelar</button>
</div>
</div>
</form>
</div>
<hr>
<div class="mb-3">
<label for="barcode-input" class="form-label">Esperando código de barras...</label>
<div class="input-group">
<input type="text" class="form-control form-control-lg" id="barcode-input" placeholder="Escanee el producto aquí" autofocus>
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#camera-modal">
<i class="fa fa-camera"></i>
</button>
</div>
<!-- Pestaña 2: Entrada Manual por Cantidad -->
<div class="tab-pane fade" id="manual-entry" role="tabpanel" aria-labelledby="manual-tab">
<form id="manual-entry-form" class="mt-3">
<div class="mb-3">
<label for="sede-manual" class="form-label">Sede de Destino</label>
<select class="form-select" id="sede-manual" name="sede_id" required>
<option value="">Seleccione una sede</option>
<?php foreach ($sedes as $sede): ?>
<option value="<?php echo htmlspecialchars($sede['id']); ?>"><?php echo htmlspecialchars($sede['nombre']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label for="product-manual" class="form-label">Producto</label>
<select class="form-select" id="product-manual" name="product_id" required>
<option value="">Seleccione un producto</option>
<?php foreach ($products as $product): ?>
<option value="<?php echo htmlspecialchars($product['id']); ?>"><?php echo htmlspecialchars($product['nombre']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label for="quantity-manual" class="form-label">Cantidad a Ingresar</label>
<input type="number" class="form-control" id="quantity-manual" name="cantidad" min="1" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary"><i class="fa fa-plus-circle"></i> Registrar Entrada Manual</button>
</div>
</form>
</div>
<div id="scanned-product-info" class="mt-3" style="display: none;">
<p><strong>Producto escaneado:</strong> <span id="scanned-code"></span></p>
<div class="d-grid gap-2">
<button type="button" id="accept-btn" class="btn btn-success"> <i class="fa fa-check-circle"></i> Aceptar y Registrar Entrada</button>
<button type="button" id="cancel-btn" class="btn btn-danger"> <i class="fa fa-times-circle"></i> Cancelar</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
@ -92,18 +147,20 @@ try {
<script>
document.addEventListener('DOMContentLoaded', (event) => {
// --- CONFIGURACIÓN INICIAL ---
// --- CONFIGURACIÓN GENERAL ---
if (typeof bootstrap === 'undefined') {
console.error('Bootstrap no está cargado.');
return;
}
const barcodeInput = document.getElementById('barcode-input');
const sedeSelect = document.getElementById('sede');
const sedeBarcodeSelect = document.getElementById('sede-barcode');
const scannedProductInfo = document.getElementById('scanned-product-info');
const scannedCodeSpan = document.getElementById('scanned-code');
const acceptBtn = document.getElementById('accept-btn');
const cancelBtn = document.getElementById('cancel-btn');
const manualForm = document.getElementById('manual-entry-form');
let processing = false;
let lastScannedCode = null;
@ -123,7 +180,7 @@ document.addEventListener('DOMContentLoaded', (event) => {
document.body.addEventListener('click', wakeUpAudio, { once: true });
document.body.addEventListener('touchstart', wakeUpAudio, { once: true });
function playBeep() {
function playBeep(success = true) {
if (!audioCtx || audioCtx.state !== 'running') {
wakeUpAudio();
if (!audioCtx || audioCtx.state !== 'running') return;
@ -133,20 +190,40 @@ document.addEventListener('DOMContentLoaded', (event) => {
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
gainNode.gain.value = 0.1;
oscillator.frequency.value = 880;
oscillator.type = 'sine';
oscillator.frequency.value = success ? 880 : 440;
oscillator.type = success ? 'sine' : 'square';
oscillator.start();
setTimeout(() => oscillator.stop(), 150);
}
// --- INICIALIZACIÓN DE CÁMARA ---
// --- NOTIFICACIONES ---
function showNotification(message, isSuccess) {
const container = document.getElementById('notification-container');
if (!container) return;
const toastId = 'toast-' + Date.now();
const toastHTML = `
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header ${isSuccess ? 'bg-success text-white' : 'bg-danger text-white'}">
<strong class="me-auto">${isSuccess ? 'Éxito' : 'Error'}</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">${message}</div>
</div>`;
container.insertAdjacentHTML('beforeend', toastHTML);
const toast = new bootstrap.Toast(document.getElementById(toastId), { delay: 5000 });
toast.show();
document.getElementById(toastId).addEventListener('hidden.bs.toast', e => e.target.remove());
}
// --- PESTAÑA: CÓDIGO DE BARRAS ---
// ** Lógica de Cámara **
const cameraModal = document.getElementById('camera-modal');
let html5QrCode;
function onScanSuccess(decodedText, decodedResult) {
const modal = bootstrap.Modal.getInstance(cameraModal);
if(modal) modal.hide();
barcodeInput.value = decodedText;
const changeEvent = new Event('change');
barcodeInput.dispatchEvent(changeEvent);
@ -166,25 +243,7 @@ document.addEventListener('DOMContentLoaded', (event) => {
}
});
// --- FUNCIONES DE AYUDA (Notificaciones) ---
function showNotification(message, isSuccess) {
const container = document.getElementById('notification-container');
if (!container) return;
const toastId = 'toast-' + Date.now();
const toastHTML = `
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header ${isSuccess ? 'bg-success text-white' : 'bg-danger text-white'}">
<strong class="me-auto">${isSuccess ? 'Éxito' : 'Error'}</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">${message}</div>
</div>`;
container.insertAdjacentHTML('beforeend', toastHTML);
const toast = new bootstrap.Toast(document.getElementById(toastId), { delay: 5000 });
toast.show();
document.getElementById(toastId).addEventListener('hidden.bs.toast', e => e.target.remove());
}
// ** Lógica del Escáner **
function resetScanner() {
lastScannedCode = null;
barcodeInput.value = '';
@ -194,33 +253,27 @@ document.addEventListener('DOMContentLoaded', (event) => {
barcodeInput.focus();
}
// --- LÓGICA PRINCIPAL DEL ESCÁNER ---
barcodeInput.addEventListener('change', function() {
const barcodeValue = this.value.trim();
if (barcodeValue === '') return;
playBeep();
lastScannedCode = barcodeValue;
scannedCodeSpan.textContent = barcodeValue;
scannedProductInfo.style.display = 'block';
this.disabled = true;
acceptBtn.focus(); // Mover foco al botón de aceptar
acceptBtn.focus();
});
// --- LÓGICA DE BOTONES ACEPTAR/CANCELAR ---
cancelBtn.addEventListener('click', function() {
resetScanner();
});
cancelBtn.addEventListener('click', resetScanner);
acceptBtn.addEventListener('click', function() {
if (processing || !lastScannedCode) return;
processing = true;
const sedeId = sedeSelect.value;
const sedeId = sedeBarcodeSelect.value;
if (!sedeId) {
showNotification("Por favor, seleccione una sede de destino.", false);
playBeep(false);
processing = false;
return;
}
@ -229,7 +282,43 @@ document.addEventListener('DOMContentLoaded', (event) => {
formData.append('codigo_unico', lastScannedCode);
formData.append('sede_id', sedeId);
fetch('registrar_entrada_api.php', {
fetch('registrar_entrada_api.php', { method: 'POST', body: formData })
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, true);
} else {
throw new Error(data.message || 'Error desconocido.');
}
})
.catch(error => {
showNotification(error.message, false);
playBeep(false);
})
.finally(() => {
resetScanner();
});
});
// --- PESTAÑA: ENTRADA MANUAL ---
manualForm.addEventListener('submit', function(e) {
e.preventDefault();
if (processing) return;
processing = true;
const formData = new FormData(this);
const sedeId = formData.get('sede_id');
const productId = formData.get('product_id');
const cantidad = formData.get('cantidad');
if (!sedeId || !productId || !cantidad || cantidad < 1) {
showNotification("Todos los campos son obligatorios y la cantidad debe ser mayor a cero.", false);
playBeep(false);
processing = false;
return;
}
fetch('registrar_entrada_manual_api.php', {
method: 'POST',
body: formData
})
@ -237,27 +326,44 @@ document.addEventListener('DOMContentLoaded', (event) => {
.then(data => {
if (data.success) {
showNotification(data.message, true);
playBeep(true);
manualForm.reset();
} else {
throw new Error(data.message || 'Error desconocido al registrar la entrada.');
}
})
.catch(error => {
showNotification(error.message, false);
playBeep(false);
})
.finally(() => {
resetScanner();
processing = false;
});
});
// Mantener el foco en el input principal cuando no se interactúa con otros elementos
barcodeInput.focus();
// --- LÓGICA DE PESTAÑAS ---
const tabs = new bootstrap.Tab(document.getElementById('barcode-tab'));
tabs.show();
// Auto-focus en el input correcto al cambiar de pestaña
document.getElementById('barcode-tab').addEventListener('shown.bs.tab', function () {
barcodeInput.focus();
});
document.getElementById('manual-tab').addEventListener('shown.bs.tab', function () {
document.getElementById('sede-manual').focus();
});
// Mantener el foco en el input principal de la pestaña de código de barras
document.body.addEventListener('click', (e) => {
// No re-enfocar si se hace clic en inputs, botones, selects, o dentro de un modal
if (!e.target.closest('input, button, select, .modal')) {
barcodeInput.focus();
const activeTab = document.querySelector('.tab-pane.active');
if (activeTab && activeTab.id === 'barcode-entry') {
if (!e.target.closest('input, button, select, .modal')) {
barcodeInput.focus();
}
}
});
});
</script>
<?php require_once 'layout_footer.php'; ?>
<?php require_once 'layout_footer.php'; ?>

View File

@ -1,19 +1,20 @@
<?php
$pageTitle = "Registro de Salida de Producto";
$pageTitle = "Registro de Salida";
require_once 'layout_header.php';
require_once 'db/config.php';
$error_page_load = '';
$sedes = [];
$products = [];
$almacen_principal_id = null;
// Obtener sedes para el dropdown
$sedes = [];
try {
$pdo = db();
// Obtener sedes
$sedes_stmt = $pdo->query("SELECT id, nombre FROM sedes ORDER BY nombre ASC");
$sedes = $sedes_stmt->fetchAll(PDO::FETCH_ASSOC);
// Encontrar el ID de "ALMACEN PRINCIPAL" para usarlo en el script
// Encontrar el ID de "ALMACEN PRINCIPAL" para la lógica móvil
foreach ($sedes as $sede) {
if (trim(strtolower($sede['nombre'])) === 'almacen principal') {
$almacen_principal_id = $sede['id'];
@ -21,23 +22,18 @@ try {
}
}
} catch (PDOException $e) {
$error_page_load = "Error al cargar datos: " . $e->getMessage();
}
// Obtener productos para el dropdown manual
$products = [];
try {
// Obtener productos
$products_stmt = $pdo->query("SELECT id, nombre, sku FROM products ORDER BY nombre ASC");
$products = $products_stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
$error_page_load .= " Error al cargar productos: " . $e->getMessage();
$error_page_load = "Error al cargar datos iniciales: " . $e->getMessage();
}
?>
<div class="container mt-4">
<div class="row">
<div class="col-lg-6 mx-auto">
<div class="col-lg-8 mx-auto">
<!-- Contenedor para notificaciones (toasts) -->
<div id="notification-container" class="position-fixed top-0 end-0 p-3" style="z-index: 1100"></div>
@ -50,75 +46,89 @@ try {
<div class="card">
<div class="card-header">
<i class="fa fa-barcode"></i> Escanear Código de Barras
<i class="fa fa-sign-out"></i> <?php echo $pageTitle; ?>
</div>
<div class="card-body">
<form id="scan-form" onsubmit="return false;">
<div class="mb-3">
<label for="movement_date" class="form-label">Fecha de Salida</label>
<input type="date" class="form-control" id="movement_date" name="movement_date" value="<?php echo date('Y-m-d'); ?>" required>
<!-- Pestañas de Navegación -->
<ul class="nav nav-tabs" id="exitTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="barcode-exit-tab" data-bs-toggle="tab" data-bs-target="#barcode-exit" type="button" role="tab" aria-controls="barcode-exit" aria-selected="true">
<i class="fa fa-barcode"></i> Por Código de Barras
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="manual-exit-tab" data-bs-toggle="tab" data-bs-target="#manual-exit" type="button" role="tab" aria-controls="manual-exit" aria-selected="false">
<i class="fa fa-edit"></i> Manual por Cantidad
</button>
</li>
</ul>
<!-- Contenido de las Pestañas -->
<div class="tab-content" id="exitTabsContent">
<!-- Pestaña 1: Salida por Código de Barras -->
<div class="tab-pane fade show active" id="barcode-exit" role="tabpanel" aria-labelledby="barcode-exit-tab">
<form id="scan-form" onsubmit="return false;" class="mt-3">
<div class="mb-3">
<label for="movement_date" class="form-label">Fecha de Salida</label>
<input type="date" class="form-control" id="movement_date" name="movement_date" value="<?php echo date('Y-m-d'); ?>" required>
</div>
<div class="mb-3" id="sede-container">
<label for="sede-barcode" class="form-label">Sede de Origen</label>
<select class="form-select" id="sede-barcode" name="sede_id" required>
<option value="">Seleccione una sede</option>
<?php foreach ($sedes as $sede): ?>
<option value="<?php echo htmlspecialchars($sede['id']); ?>"><?php echo htmlspecialchars($sede['nombre']); ?></option>
<?php endforeach; ?>
</select>
</div>
<hr>
<div class="mb-3">
<label for="barcode-input" class="form-label">Esperando código de barras...</label>
<div class="input-group">
<input type="text" class="form-control form-control-lg" id="barcode-input" placeholder="Escanee el producto aquí" autofocus>
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#camera-modal">
<i class="fa fa-camera"></i>
</button>
</div>
</div>
</form>
</div>
<!-- El contenedor de la sede ahora tiene un ID para poder ocultarlo -->
<div class="mb-3" id="sede-container">
<label for="sede" class="form-label">Sede de Origen</label>
<select class="form-select" id="sede" name="sede_id" required>
<option value="">Seleccione una sede</option>
<?php foreach ($sedes as $sede): ?>
<option value="<?php echo htmlspecialchars($sede['id']); ?>"><?php echo htmlspecialchars($sede['nombre']); ?></option>
<?php endforeach; ?>
</select>
<!-- Pestaña 2: Salida Manual por Cantidad -->
<div class="tab-pane fade" id="manual-exit" role="tabpanel" aria-labelledby="manual-exit-tab">
<form id="manual-exit-form" onsubmit="return false;" class="mt-3">
<div class="mb-3">
<label for="manual_sede" class="form-label">Sede de Origen</label>
<select class="form-select" id="manual_sede" name="sede_id" required>
<option value="">Seleccione una sede</option>
<?php foreach ($sedes as $sede): ?>
<option value="<?php echo htmlspecialchars($sede['id']); ?>"><?php echo htmlspecialchars($sede['nombre']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label for="manual_product" class="form-label">Producto</label>
<select class="form-select" id="manual_product" name="product_id" required>
<option value="">Seleccione un producto</option>
<?php foreach ($products as $product): ?>
<option value="<?php echo htmlspecialchars($product['id']); ?>">
<?php echo htmlspecialchars($product['nombre']) . ' (' . htmlspecialchars($product['sku'] ?: 'Sin SKU') . ')'; ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label for="manual_quantity" class="form-label">Cantidad a Retirar</label>
<input type="number" class="form-control" id="manual_quantity" name="quantity" min="1" placeholder="Escriba la cantidad" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-warning"><i class="fa fa-minus-circle"></i> Registrar Salida Manual</button>
</div>
</form>
</div>
<hr>
<div class="mb-3">
<label for="barcode-input" class="form-label">Esperando código de barras...</label>
<div class="input-group">
<input type="text" class="form-control form-control-lg" id="barcode-input" placeholder="Escanee el producto aquí" autofocus>
<button class="btn btn-outline-secondary" type="button" id="scan-camera-btn" data-bs-toggle="modal" data-bs-target="#camera-modal">
<i class="fa fa-camera"></i>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<i class="fa fa-edit"></i> Salida Manual de Stock
</div>
<div class="card-body">
<p class="card-text">Utilice este formulario para ajustar el inventario y poner en cero el stock de productos antiguos o sin SKU.</p>
<form id="manual-exit-form" onsubmit="return false;">
<div class="mb-3">
<label for="manual_sede" class="form-label">Sede de Origen</label>
<select class="form-select" id="manual_sede" name="sede_id" required>
<option value="">Seleccione una sede</option>
<?php foreach ($sedes as $sede): ?>
<option value="<?php echo htmlspecialchars($sede['id']); ?>"><?php echo htmlspecialchars($sede['nombre']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label for="manual_product" class="form-label">Producto</label>
<select class="form-select" id="manual_product" name="product_id" required>
<option value="">Seleccione un producto</option>
<?php foreach ($products as $product): ?>
<option value="<?php echo htmlspecialchars($product['id']); ?>">
<?php echo htmlspecialchars($product['nombre']) . ' (' . htmlspecialchars($product['sku'] ?: 'Sin SKU') . ')'; ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label for="manual_quantity" class="form-label">Cantidad a Retirar</label>
<input type="number" class="form-control" id="manual_quantity" name="quantity" min="1" placeholder="Escriba la cantidad exacta a retirar" required>
</div>
<button type="submit" class="btn btn-warning w-100">Registrar Salida Manual y Poner en Cero</button>
</form>
</div>
</div>
</div>
</div>
</div>
@ -145,38 +155,35 @@ try {
<script>
document.addEventListener('DOMContentLoaded', (event) => {
// --- CONFIGURACIÓN INICIAL ---
// --- CONFIGURACIÓN GENERAL ---
if (typeof bootstrap === 'undefined') {
console.error('Bootstrap no está cargado.');
return;
}
const barcodeInput = document.getElementById('barcode-input');
const sedeSelect = document.getElementById('sede');
const sedeBarcodeSelect = document.getElementById('sede-barcode');
const dateInput = document.getElementById('movement_date');
const sedeContainer = document.getElementById('sede-container');
const manualExitForm = document.getElementById('manual-exit-form');
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const almacenPrincipalId = '<?php echo $almacen_principal_id; ?>';
let processing = false;
// --- LÓGICA DE SONIDO (WEB AUDIO API) ---
// --- LÓGICA DE SONIDO ---
let audioCtx;
function wakeUpAudio() {
if (!audioCtx) {
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) { console.error("Web Audio API no es soportada.", e); return; }
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return; }
}
if (audioCtx.state === 'suspended') audioCtx.resume();
}
document.body.addEventListener('click', wakeUpAudio, { once: true });
document.body.addEventListener('touchstart', wakeUpAudio, { once: true });
function playBeep() {
function playBeep(success = true) {
if (!audioCtx || audioCtx.state !== 'running') {
wakeUpAudio();
if (!audioCtx || audioCtx.state !== 'running') return;
@ -185,25 +192,46 @@ document.addEventListener('DOMContentLoaded', (event) => {
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
gainNode.gain.value = 0.1;
oscillator.frequency.value = 880;
oscillator.type = 'sine';
gainNode.gain.value = 0.1;
oscillator.frequency.value = success ? 880 : 440;
oscillator.type = success ? 'sine' : 'square';
oscillator.start();
setTimeout(() => oscillator.stop(), 150);
}
// --- LÓGICA PARA MÓVIL ---
if (isMobile) {
if(sedeContainer) sedeContainer.style.display = 'none';
if(sedeSelect && almacenPrincipalId) sedeSelect.value = almacenPrincipalId;
// --- NOTIFICACIONES ---
function showNotification(message, isSuccess) {
const container = document.getElementById('notification-container');
if (!container) return;
const toastId = 'toast-' + Date.now();
const toastHTML = `
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header ${isSuccess ? 'bg-success text-white' : 'bg-danger text-white'}">
<strong class="me-auto">${isSuccess ? 'Éxito' : 'Error'}</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">${message}</div>
</div>`;
container.insertAdjacentHTML('beforeend', toastHTML);
const toast = new bootstrap.Toast(document.getElementById(toastId), { delay: 5000 });
toast.show();
document.getElementById(toastId).addEventListener('hidden.bs.toast', e => e.target.remove());
}
// --- INICIALIZACIÓN DE CÁMARA ---
// --- LÓGICA PARA MÓVIL (SOLO PESTAÑA CÓDIGO DE BARRAS) ---
if (isMobile && almacenPrincipalId) {
if(sedeContainer) sedeContainer.style.display = 'none';
if(sedeBarcodeSelect) sedeBarcodeSelect.value = almacenPrincipalId;
}
// --- PESTAÑA: CÓDIGO DE BARRAS ---
// ** Lógica de Cámara **
const cameraModal = document.getElementById('camera-modal');
let html5QrCode;
function onScanSuccess(decodedText, decodedResult) {
playBeep();
playBeep();
barcodeInput.value = decodedText;
const changeEvent = new Event('change');
barcodeInput.dispatchEvent(changeEvent);
@ -225,26 +253,7 @@ document.addEventListener('DOMContentLoaded', (event) => {
}
});
// --- FUNCIONES DE AYUDA (Notificaciones) ---
function showNotification(message, isSuccess) {
const container = document.getElementById('notification-container');
if (!container) return;
const toastId = 'toast-' + Date.now();
const toastHTML = `
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header ${isSuccess ? 'bg-success text-white' : 'bg-danger text-white'}">
<strong class="me-auto">${isSuccess ? 'Éxito' : 'Error'}</strong>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">${message}</div>
</div>`;
container.insertAdjacentHTML('beforeend', toastHTML);
const toast = new bootstrap.Toast(document.getElementById(toastId), { delay: 5000 });
toast.show();
document.getElementById(toastId).addEventListener('hidden.bs.toast', e => e.target.remove());
}
// --- LÓGICA PRINCIPAL DEL ESCÁNER ---
// ** Lógica del Escáner **
barcodeInput.addEventListener('change', function() {
const barcodeValue = this.value.trim();
if (barcodeValue === '' || processing) return;
@ -252,11 +261,10 @@ document.addEventListener('DOMContentLoaded', (event) => {
processing = true;
this.disabled = true;
const sedeId = sedeSelect.value;
const movementDate = dateInput.value; // Although not used by new API, good to have if needed later
const sedeId = sedeBarcodeSelect.value;
if (!sedeId) {
showNotification("Por favor, seleccione una sede de origen.", false);
playBeep(false);
this.value = '';
this.disabled = false;
processing = false;
@ -268,20 +276,18 @@ document.addEventListener('DOMContentLoaded', (event) => {
formData.append('codigo_unico', barcodeValue);
formData.append('sede_id', sedeId);
fetch('registrar_salida_unidad_api.php', {
method: 'POST',
body: formData
})
fetch('registrar_salida_unidad_api.php', { method: 'POST', body: formData })
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, true);
} else {
throw new Error(data.message || 'Error desconocido al registrar la salida.');
throw new Error(data.message || 'Error desconocido.');
}
})
.catch(error => {
showNotification(error.message, false);
playBeep(false);
})
.finally(() => {
this.value = '';
@ -291,62 +297,70 @@ document.addEventListener('DOMContentLoaded', (event) => {
});
});
// --- LÓGICA DEL FORMULARIO MANUAL ---
const manualExitForm = document.getElementById('manual-exit-form');
// --- PESTAÑA: SALIDA MANUAL ---
if (manualExitForm) {
manualExitForm.addEventListener('submit', function(e) {
e.preventDefault();
if (processing) return;
const sedeId = document.getElementById('manual_sede').value;
const productId = document.getElementById('manual_product').value;
const quantity = document.getElementById('manual_quantity').value;
const formData = new FormData(this);
const sedeId = formData.get('sede_id');
const productId = formData.get('product_id');
const quantity = formData.get('quantity');
if (!sedeId || !productId || !quantity) {
showNotification("Por favor, complete todos los campos del formulario manual.", false);
if (!sedeId || !productId || !quantity || parseInt(quantity, 10) <= 0) {
showNotification("Todos los campos son obligatorios y la cantidad debe ser positiva.", false);
playBeep(false);
return;
}
if (parseInt(quantity, 10) <= 0) {
showNotification("La cantidad debe ser un número positivo.", false);
return;
}
if (!confirm(`¿Está seguro de que desea retirar ${quantity} unidad(es) de este producto? Esta acción ajustará el inventario.`)) {
return;
}
processing = true;
const formData = new FormData();
formData.append('sede_id', sedeId);
formData.append('product_id', productId);
formData.append('quantity', quantity);
fetch('registrar_salida_manual_api.php', {
method: 'POST',
body: formData
})
fetch('registrar_salida_manual_api.php', { method: 'POST', body: formData })
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, true);
playBeep(true);
manualExitForm.reset();
} else {
throw new Error(data.message || 'Error desconocido al registrar la salida manual.');
throw new Error(data.message || 'Error desconocido.');
}
})
.catch(error => {
showNotification(error.message, false);
playBeep(false);
})
.finally(() => {
processing = false;
});
});
}
// Mantener el foco en el input
barcodeInput.focus();
// --- LÓGICA DE PESTAÑAS ---
const tabs = new bootstrap.Tab(document.getElementById('barcode-exit-tab'));
tabs.show();
document.getElementById('barcode-exit-tab').addEventListener('shown.bs.tab', function () {
barcodeInput.focus();
});
document.getElementById('manual-exit-tab').addEventListener('shown.bs.tab', function () {
document.getElementById('manual_sede').focus();
});
document.body.addEventListener('click', (e) => {
if (!['INPUT', 'SELECT', 'BUTTON', 'A'].includes(e.target.tagName) && !e.target.closest('button, a, .modal')) {
barcodeInput.focus();
const activeTab = document.querySelector('.tab-pane.active');
if (activeTab && activeTab.id === 'barcode-exit') {
if (!e.target.closest('input, button, select, .modal')) {
barcodeInput.focus();
}
}
});
});
</script>
<?php require_once 'layout_footer.php'; ?>
<?php require_once 'layout_footer.php'; ?>