feat(users): improve user editing and ticket details

- Add user's full name to the database and UI.\n- Allow admins to edit user details (name, username, role).\n- Allow users to edit their own name and password.\n- Automatically set the 'staff' field on new tickets to the current user's name.\n- Bold the 'Te atendió:' label in the printed ticket.
This commit is contained in:
Marco Gallegos
2025-08-13 08:18:04 -06:00
parent 96ffed6674
commit 164eaccac1
4 changed files with 130 additions and 37 deletions

View File

@@ -234,9 +234,11 @@ function renderUsersTable() {
users.forEach(u => { users.forEach(u => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
<td>${u.name}</td>
<td>${u.username}</td> <td>${u.username}</td>
<td>${u.role === 'admin' ? 'Administrador' : 'Usuario'}</td> <td>${u.role === 'admin' ? 'Administrador' : 'Usuario'}</td>
<td> <td>
<button class="action-btn" data-id="${u.id}" data-action="edit-user">Editar</button>
${u.id !== currentUser.id ? `<button class="action-btn" data-id="${u.id}" data-action="delete-user">Eliminar</button>` : ''} ${u.id !== currentUser.id ? `<button class="action-btn" data-id="${u.id}" data-action="delete-user">Eliminar</button>` : ''}
</td> </td>
`; `;
@@ -276,10 +278,11 @@ async function handleSaveSettings(e) {
async function handleSaveCredentials(e) { async function handleSaveCredentials(e) {
e.preventDefault(); e.preventDefault();
const name = document.getElementById('s-name').value;
const username = document.getElementById('s-username').value; const username = document.getElementById('s-username').value;
const password = document.getElementById('s-password').value; const password = document.getElementById('s-password').value;
const body = { username }; const body = { username, name };
if (password) { if (password) {
body.password = password; body.password = password;
} }
@@ -293,6 +296,8 @@ async function handleSaveCredentials(e) {
if (response.ok) { if (response.ok) {
alert('Credenciales actualizadas.'); alert('Credenciales actualizadas.');
currentUser.name = name; // Actualizar el nombre en el estado local
currentUser.username = username;
document.getElementById('s-password').value = ''; document.getElementById('s-password').value = '';
} else { } else {
const error = await response.json(); const error = await response.json();
@@ -303,31 +308,54 @@ async function handleSaveCredentials(e) {
} }
} }
async function handleAddUser(e) { async function handleAddOrUpdateUser(e) {
e.preventDefault(); e.preventDefault();
const id = document.getElementById('u-id').value;
const name = document.getElementById('u-name').value;
const username = document.getElementById('u-username').value; const username = document.getElementById('u-username').value;
const password = document.getElementById('u-password').value; const password = document.getElementById('u-password').value;
const role = document.getElementById('u-role').value; const role = document.getElementById('u-role').value;
const isUpdate = !!id;
const url = isUpdate ? `/api/users/${id}` : '/api/users';
const method = isUpdate ? 'PUT' : 'POST';
const body = { name, username, role };
if (password || !isUpdate) {
if (!password && !isUpdate) {
alert('La contraseña es obligatoria para nuevos usuarios.');
return;
}
body.password = password;
}
try { try {
const response = await fetch('/api/users', { const response = await fetch(url, {
method: 'POST', method: method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role }) body: JSON.stringify(body)
}); });
const newUser = await response.json(); const result = await response.json();
if (response.ok) { if (response.ok) {
alert('Usuario creado exitosamente.'); alert(`Usuario ${isUpdate ? 'actualizado' : 'creado'} exitosamente.`);
users.push(newUser); if (isUpdate) {
const index = users.findIndex(u => u.id === parseInt(id));
if (index > -1) {
users[index] = { ...users[index], name, username, role };
}
} else {
users.push(result);
}
renderUsersTable(); renderUsersTable();
formAddUser.reset(); formAddUser.reset();
document.getElementById('u-id').value = '';
} else { } else {
alert(`Error: ${newUser.error}`); alert(`Error: ${result.error}`);
} }
} catch (error) { } catch (error) {
alert('Error de conexión al crear usuario.'); alert('Error de conexión al guardar el usuario.');
} }
} }
@@ -381,7 +409,7 @@ async function handleNewMovement(e) {
monto: Number(monto.toFixed(2)), monto: Number(monto.toFixed(2)),
metodo: document.getElementById('m-metodo').value, metodo: document.getElementById('m-metodo').value,
concepto: document.getElementById('m-concepto').value, concepto: document.getElementById('m-concepto').value,
staff: document.getElementById('m-staff').value, staff: currentUser.name, // Usar el nombre del usuario actual
notas: document.getElementById('m-notas').value, notas: document.getElementById('m-notas').value,
fechaCita: document.getElementById('m-fecha-cita').value, fechaCita: document.getElementById('m-fecha-cita').value,
horaCita: document.getElementById('m-hora-cita').value, horaCita: document.getElementById('m-hora-cita').value,
@@ -424,6 +452,16 @@ function handleTableClick(e) {
deleteClient(id); deleteClient(id);
} }
} }
} else if (action === 'edit-user') {
const user = users.find(u => u.id === parseInt(id));
if (user) {
document.getElementById('u-id').value = user.id;
document.getElementById('u-name').value = user.name;
document.getElementById('u-username').value = user.username;
document.getElementById('u-role').value = user.role;
document.getElementById('u-password').value = ''; // Limpiar campo de contraseña
document.getElementById('u-password').placeholder = 'Dejar en blanco para no cambiar';
}
} else if (action === 'delete-user') { } else if (action === 'delete-user') {
deleteUser(parseInt(id, 10)); deleteUser(parseInt(id, 10));
} }
@@ -508,6 +546,7 @@ function setupUIForRole(role) {
const dashboardTab = document.querySelector('[data-tab="tab-dashboard"]'); const dashboardTab = document.querySelector('[data-tab="tab-dashboard"]');
const settingsTab = document.querySelector('[data-tab="tab-settings"]'); const settingsTab = document.querySelector('[data-tab="tab-settings"]');
const userManagementSection = document.getElementById('user-management-section'); const userManagementSection = document.getElementById('user-management-section');
const staffInput = document.getElementById('m-staff');
if (role === 'admin') { if (role === 'admin') {
// El admin puede ver todo // El admin puede ver todo
@@ -526,6 +565,11 @@ function setupUIForRole(role) {
settingsTab.style.display = 'none'; settingsTab.style.display = 'none';
userManagementSection.style.display = 'none'; userManagementSection.style.display = 'none';
} }
// Deshabilitar el campo "Atendió" para todos, ya que se autocompleta
if (staffInput) {
staffInput.disabled = true;
}
} }
@@ -563,6 +607,7 @@ async function initializeApp() {
// 3. Añadir manejadores de eventos. // 3. Añadir manejadores de eventos.
const tabs = document.querySelector('.tabs'); const tabs = document.querySelector('.tabs');
const btnLogout = document.getElementById('btnLogout'); const btnLogout = document.getElementById('btnLogout');
const btnCancelEditUser = document.getElementById('btnCancelEditUser');
formSettings?.addEventListener('submit', handleSaveSettings); formSettings?.addEventListener('submit', handleSaveSettings);
formCredentials?.addEventListener('submit', handleSaveCredentials); formCredentials?.addEventListener('submit', handleSaveCredentials);
@@ -575,7 +620,7 @@ async function initializeApp() {
tabs?.addEventListener('click', handleTabChange); tabs?.addEventListener('click', handleTabChange);
if (currentUser.role === 'admin') { if (currentUser.role === 'admin') {
formAddUser?.addEventListener('submit', handleAddUser); formAddUser?.addEventListener('submit', handleAddOrUpdateUser);
tblUsersBody?.addEventListener('click', handleTableClick); tblUsersBody?.addEventListener('click', handleTableClick);
} }
@@ -589,6 +634,13 @@ async function initializeApp() {
document.getElementById('c-id').value = ''; document.getElementById('c-id').value = '';
}); });
btnCancelEditUser?.addEventListener('click', (e) => {
e.preventDefault();
formAddUser.reset();
document.getElementById('u-id').value = '';
document.getElementById('u-password').placeholder = 'Contraseña';
});
// 4. Cargar el resto de los datos de la aplicación. // 4. Cargar el resto de los datos de la aplicación.
Promise.all([ Promise.all([
load(KEY_SETTINGS, DEFAULT_SETTINGS), load(KEY_SETTINGS, DEFAULT_SETTINGS),
@@ -603,7 +655,9 @@ async function initializeApp() {
updateClientDatalist(); updateClientDatalist();
if (currentUser) { if (currentUser) {
document.getElementById('s-name').value = currentUser.name || '';
document.getElementById('s-username').value = currentUser.username; document.getElementById('s-username').value = currentUser.username;
document.getElementById('m-staff').value = currentUser.name || '';
} }
// 5. Configurar la UI y activar la pestaña inicial correcta. // 5. Configurar la UI y activar la pestaña inicial correcta.

View File

@@ -183,6 +183,8 @@
<h2>Mis Credenciales</h2> <h2>Mis Credenciales</h2>
<form id="formCredentials"> <form id="formCredentials">
<div class="form-grid"> <div class="form-grid">
<label>Mi Nombre:</label>
<input type="text" id="s-name" required />
<label>Mi Usuario:</label> <label>Mi Usuario:</label>
<input type="text" id="s-username" required /> <input type="text" id="s-username" required />
<label>Nueva Contraseña:</label> <label>Nueva Contraseña:</label>
@@ -197,13 +199,16 @@
<div class="section" id="user-management-section" style="display: none;"> <div class="section" id="user-management-section" style="display: none;">
<h2>Gestión de Usuarios</h2> <h2>Gestión de Usuarios</h2>
<div class="sub-section"> <div class="sub-section">
<h3>Añadir Nuevo Usuario</h3> <h3>Añadir/Editar Usuario</h3>
<form id="formAddUser"> <form id="formAddUser">
<input type="hidden" id="u-id" />
<div class="form-grid"> <div class="form-grid">
<label>Nuevo Usuario:</label> <label>Nombre:</label>
<input type="text" id="u-name" required />
<label>Usuario:</label>
<input type="text" id="u-username" required /> <input type="text" id="u-username" required />
<label>Contraseña:</label> <label>Contraseña:</label>
<input type="password" id="u-password" required /> <input type="password" id="u-password" placeholder="Dejar en blanco para no cambiar" />
<label>Rol:</label> <label>Rol:</label>
<select id="u-role"> <select id="u-role">
<option value="user">Usuario (Ventas)</option> <option value="user">Usuario (Ventas)</option>
@@ -211,7 +216,8 @@
</select> </select>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit">Crear Usuario</button> <button type="submit">Guardar Usuario</button>
<button type="reset" id="btnCancelEditUser" class="btn-danger">Cancelar Edición</button>
</div> </div>
</form> </form>
</div> </div>
@@ -221,6 +227,7 @@
<table id="tblUsers"> <table id="tblUsers">
<thead> <thead>
<tr> <tr>
<th>Nombre</th>
<th>Usuario</th> <th>Usuario</th>
<th>Rol</th> <th>Rol</th>
<th>Acciones</th> <th>Acciones</th>

View File

@@ -42,7 +42,7 @@ function templateTicket(mov, settings) {
lines.push(`<div><span class="t-bold">${esc(mov.tipo)}</span></div>`); lines.push(`<div><span class="t-bold">${esc(mov.tipo)}</span></div>`);
if (mov.cliente) lines.push(`<div class="t-small">Cliente: ${esc(mov.cliente)}</div>`); if (mov.cliente) lines.push(`<div class="t-small">Cliente: ${esc(mov.cliente)}</div>`);
if (mov.concepto) lines.push(`<div class="t-small">Concepto: ${esc(mov.concepto)}</div>`); if (mov.concepto) lines.push(`<div class="t-small">Concepto: ${esc(mov.concepto)}</div>`);
if (mov.staff) lines.push(`<div class="t-small">Atendió: ${esc(mov.staff)}</div>`); if (mov.staff) lines.push(`<div class="t-small"><b>Te atendió:</b> ${esc(mov.staff)}</div>`);
if (mov.metodo) lines.push(`<div class="t-small">Método: ${esc(mov.metodo)}</div>`); if (mov.metodo) lines.push(`<div class="t-small">Método: ${esc(mov.metodo)}</div>`);
if (mov.notas) lines.push(`<div class="t-small">Notas: ${esc(mov.notas)}</div>`); if (mov.notas) lines.push(`<div class="t-small">Notas: ${esc(mov.notas)}</div>`);

View File

@@ -42,26 +42,34 @@ db.serialize(() => {
} }
}); });
db.run("ALTER TABLE users ADD COLUMN name TEXT", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding name column to users table:", err.message);
}
});
db.run(`CREATE TABLE IF NOT EXISTS users ( db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE, username TEXT UNIQUE,
password TEXT, password TEXT,
role TEXT DEFAULT 'user' role TEXT DEFAULT 'user',
name TEXT
)`, (err) => { )`, (err) => {
if (err) return; if (err) return;
// Solo intentar insertar si la tabla fue creada o ya existía // Solo intentar insertar si la tabla fue creada o ya existía
const adminUsername = 'admin'; const adminUsername = 'admin';
const defaultPassword = 'password'; const defaultPassword = 'password';
const defaultName = 'Admin'; // Nombre por defecto para el admin
db.get('SELECT * FROM users WHERE username = ?', [adminUsername], (err, row) => { db.get('SELECT * FROM users WHERE username = ?', [adminUsername], (err, row) => {
if (err) return; if (err) return;
if (!row) { if (!row) {
bcrypt.hash(defaultPassword, SALT_ROUNDS, (err, hash) => { bcrypt.hash(defaultPassword, SALT_ROUNDS, (err, hash) => {
if (err) return; if (err) return;
// Insertar admin con su rol // Insertar admin con su rol y nombre
db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [adminUsername, hash, 'admin'], (err) => { db.run('INSERT INTO users (username, password, role, name) VALUES (?, ?, ?, ?)', [adminUsername, hash, 'admin', defaultName], (err) => {
if (!err) { if (!err) {
console.log(`Default user '${adminUsername}' created with password '${defaultPassword}' and role 'admin'. Please change it.`); console.log(`Default user '${adminUsername}' created with name '${defaultName}', password '${defaultPassword}' and role 'admin'. Please change it.`);
} }
}); });
}); });
@@ -129,7 +137,8 @@ app.post('/api/login', (req, res) => {
} }
req.session.userId = user.id; req.session.userId = user.id;
req.session.role = user.role; // Guardar rol en la sesión req.session.role = user.role; // Guardar rol en la sesión
res.json({ message: 'Login successful', role: user.role }); req.session.name = user.name; // Guardar nombre en la sesión
res.json({ message: 'Login successful', role: user.role, name: user.name });
}); });
}); });
}); });
@@ -147,7 +156,7 @@ app.post('/api/logout', (req, res) => {
// Endpoint para verificar el estado de la autenticación en el frontend // Endpoint para verificar el estado de la autenticación en el frontend
app.get('/api/check-auth', (req, res) => { app.get('/api/check-auth', (req, res) => {
if (req.session.userId) { if (req.session.userId) {
res.json({ isAuthenticated: true, role: req.session.role }); res.json({ isAuthenticated: true, role: req.session.role, name: req.session.name });
} else { } else {
res.json({ isAuthenticated: false }); res.json({ isAuthenticated: false });
} }
@@ -267,7 +276,7 @@ app.use('/api', apiRouter);
// --- User Management (Admin) --- // --- User Management (Admin) ---
// Proteger estas rutas para que solo los admins puedan usarlas // Proteger estas rutas para que solo los admins puedan usarlas
apiRouter.get('/users', isAdmin, (req, res) => { apiRouter.get('/users', isAdmin, (req, res) => {
db.all("SELECT id, username, role FROM users", [], (err, rows) => { db.all("SELECT id, username, role, name FROM users", [], (err, rows) => {
if (err) { if (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
return; return;
@@ -277,9 +286,9 @@ apiRouter.get('/users', isAdmin, (req, res) => {
}); });
apiRouter.post('/users', isAdmin, (req, res) => { apiRouter.post('/users', isAdmin, (req, res) => {
const { username, password, role } = req.body; const { username, password, role, name } = req.body;
if (!username || !password || !role) { if (!username || !password || !role || !name) {
return res.status(400).json({ error: 'Username, password, and role are required' }); return res.status(400).json({ error: 'Username, password, name, and role are required' });
} }
if (role !== 'admin' && role !== 'user') { if (role !== 'admin' && role !== 'user') {
return res.status(400).json({ error: 'Invalid role' }); return res.status(400).json({ error: 'Invalid role' });
@@ -289,18 +298,41 @@ apiRouter.post('/users', isAdmin, (req, res) => {
if (err) { if (err) {
return res.status(500).json({ error: 'Error hashing password' }); return res.status(500).json({ error: 'Error hashing password' });
} }
db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [username, hash, role], function(err) { db.run('INSERT INTO users (username, password, role, name) VALUES (?, ?, ?, ?)', [username, hash, role, name], function(err) {
if (err) { if (err) {
if (err.message.includes('UNIQUE constraint failed')) { if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Username already exists' }); return res.status(409).json({ error: 'Username already exists' });
} }
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
} }
res.status(201).json({ id: this.lastID, username, role }); res.status(201).json({ id: this.lastID, username, role, name });
}); });
}); });
}); });
// Nueva ruta para actualizar un usuario (solo admin)
apiRouter.put('/users/:id', isAdmin, (req, res) => {
const { id } = req.params;
const { username, role, name } = req.body;
if (!username || !role || !name) {
return res.status(400).json({ error: 'Username, role, and name are required' });
}
db.run('UPDATE users SET username = ?, role = ?, name = ? WHERE id = ?', [username, role, name, id], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Username already exists' });
}
return res.status(500).json({ error: err.message });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ message: 'User updated successfully' });
});
});
apiRouter.delete('/users/:id', isAdmin, (req, res) => { apiRouter.delete('/users/:id', isAdmin, (req, res) => {
const { id } = req.params; const { id } = req.params;
// Prevenir que el admin se elimine a sí mismo // Prevenir que el admin se elimine a sí mismo
@@ -321,7 +353,7 @@ apiRouter.delete('/users/:id', isAdmin, (req, res) => {
// --- Current User Settings --- // --- Current User Settings ---
apiRouter.get('/user', (req, res) => { apiRouter.get('/user', (req, res) => {
db.get("SELECT id, username, role FROM users WHERE id = ?", [req.session.userId], (err, row) => { db.get("SELECT id, username, role, name FROM users WHERE id = ?", [req.session.userId], (err, row) => {
if (err) { if (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
return; return;
@@ -331,9 +363,9 @@ apiRouter.get('/user', (req, res) => {
}); });
apiRouter.post('/user', (req, res) => { apiRouter.post('/user', (req, res) => {
const { username, password } = req.body; const { username, password, name } = req.body;
if (!username) { if (!username || !name) {
return res.status(400).json({ error: 'Username is required' }); return res.status(400).json({ error: 'Username and name are required' });
} }
if (password) { if (password) {
@@ -342,7 +374,7 @@ apiRouter.post('/user', (req, res) => {
if (err) { if (err) {
return res.status(500).json({ error: 'Error hashing password' }); return res.status(500).json({ error: 'Error hashing password' });
} }
db.run('UPDATE users SET username = ?, password = ? WHERE id = ?', [username, hash, req.session.userId], function(err) { db.run('UPDATE users SET username = ?, password = ?, name = ? WHERE id = ?', [username, hash, name, req.session.userId], function(err) {
if (err) { if (err) {
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
} }
@@ -350,12 +382,12 @@ apiRouter.post('/user', (req, res) => {
}); });
}); });
} else { } else {
// Si no se proporciona contraseña, solo actualizar el nombre de usuario // Si no se proporciona contraseña, solo actualizar el nombre de usuario y el nombre
db.run('UPDATE users SET username = ? WHERE id = ?', [username, req.session.userId], function(err) { db.run('UPDATE users SET username = ?, name = ? WHERE id = ?', [username, name, req.session.userId], function(err) {
if (err) { if (err) {
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
} }
res.json({ message: 'Username updated successfully' }); res.json({ message: 'Username and name updated successfully' });
}); });
} }
}); });