feat: admin clients module with CRUD, credentials, documents
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
649
admin/cliente-detalle.php
Normal file
649
admin/cliente-detalle.php
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
<?php
|
||||||
|
$pageTitle = 'Detalle Cliente';
|
||||||
|
require_once __DIR__ . '/../includes/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/encryption.php';
|
||||||
|
require_once __DIR__ . '/../includes/config.php';
|
||||||
|
require_once __DIR__ . '/includes/admin-header.php';
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
// Determine mode: edit (id exists) or create (no id)
|
||||||
|
$clienteId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
$isEdit = $clienteId > 0;
|
||||||
|
$cliente = null;
|
||||||
|
$errors = [];
|
||||||
|
$success = '';
|
||||||
|
|
||||||
|
// Labels for tramites
|
||||||
|
$tipoLabels = [
|
||||||
|
'visa' => 'Visa',
|
||||||
|
'sentri' => 'Sentri/Global',
|
||||||
|
'pasaporte' => 'Pasaporte',
|
||||||
|
'adelanto_cita' => 'Adelanto Cita',
|
||||||
|
'doble_nacionalidad' => 'Doble Nacionalidad',
|
||||||
|
];
|
||||||
|
$estadoLabels = [
|
||||||
|
'nuevo' => 'Nuevo',
|
||||||
|
'en_proceso' => 'En Proceso',
|
||||||
|
'en_revision' => 'En Revisión',
|
||||||
|
'completado' => 'Completado',
|
||||||
|
'cancelado' => 'Cancelado',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Handle POST actions ─────────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrfValidate()) {
|
||||||
|
$action = $_POST['action'] ?? 'save_client';
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
|
||||||
|
// ── Save client (create / update) ─────────────────────────
|
||||||
|
case 'save_client':
|
||||||
|
$nombre = trim($_POST['nombre'] ?? '');
|
||||||
|
$telefono = trim($_POST['telefono'] ?? '');
|
||||||
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
$direccion = trim($_POST['direccion'] ?? '');
|
||||||
|
$notas = trim($_POST['notas'] ?? '');
|
||||||
|
|
||||||
|
if ($nombre === '') {
|
||||||
|
$errors[] = 'El nombre es obligatorio.';
|
||||||
|
}
|
||||||
|
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors[] = 'El email no es válido.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($errors)) {
|
||||||
|
if ($isEdit) {
|
||||||
|
$stmt = $db->prepare("UPDATE clientes SET nombre=?, telefono=?, email=?, direccion=?, notas=? WHERE id=?");
|
||||||
|
$stmt->execute([$nombre, $telefono, $email, $direccion, $notas, $clienteId]);
|
||||||
|
header("Location: cliente-detalle.php?id=$clienteId&saved=1");
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("INSERT INTO clientes (nombre, telefono, email, direccion, notas) VALUES (?,?,?,?,?)");
|
||||||
|
$stmt->execute([$nombre, $telefono, $email, $direccion, $notas]);
|
||||||
|
$newId = $db->lastInsertId();
|
||||||
|
|
||||||
|
// If created from solicitud, link them
|
||||||
|
$fromSolicitud = (int)($_POST['from_solicitud'] ?? 0);
|
||||||
|
if ($fromSolicitud > 0) {
|
||||||
|
$stmt2 = $db->prepare("UPDATE solicitudes SET cliente_id=?, estado='convertida' WHERE id=?");
|
||||||
|
$stmt2->execute([$newId, $fromSolicitud]);
|
||||||
|
}
|
||||||
|
|
||||||
|
header("Location: cliente-detalle.php?id=$newId&saved=1");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── Add credential ────────────────────────────────────────
|
||||||
|
case 'add_credential':
|
||||||
|
if (!$isEdit) break;
|
||||||
|
|
||||||
|
$portal = trim($_POST['cred_portal'] ?? '');
|
||||||
|
$usuario = trim($_POST['cred_usuario'] ?? '');
|
||||||
|
$password = $_POST['cred_password'] ?? '';
|
||||||
|
$credNotas = trim($_POST['cred_notas'] ?? '');
|
||||||
|
|
||||||
|
if ($portal === '' || $usuario === '' || $password === '') {
|
||||||
|
$errors[] = 'Portal, usuario y contraseña son obligatorios.';
|
||||||
|
} else {
|
||||||
|
$passwordEnc = encryptData($password);
|
||||||
|
$stmt = $db->prepare("INSERT INTO credenciales (cliente_id, portal, usuario, password_enc, notas) VALUES (?,?,?,?,?)");
|
||||||
|
$stmt->execute([$clienteId, $portal, $usuario, $passwordEnc, $credNotas]);
|
||||||
|
header("Location: cliente-detalle.php?id=$clienteId&cred_saved=1#credenciales");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── Delete credential ─────────────────────────────────────
|
||||||
|
case 'delete_credential':
|
||||||
|
if (!$isEdit) break;
|
||||||
|
|
||||||
|
$credId = (int)($_POST['cred_id'] ?? 0);
|
||||||
|
if ($credId > 0) {
|
||||||
|
$stmt = $db->prepare("DELETE FROM credenciales WHERE id=? AND cliente_id=?");
|
||||||
|
$stmt->execute([$credId, $clienteId]);
|
||||||
|
}
|
||||||
|
header("Location: cliente-detalle.php?id=$clienteId&cred_deleted=1#credenciales");
|
||||||
|
exit;
|
||||||
|
|
||||||
|
// ── Upload document ───────────────────────────────────────
|
||||||
|
case 'upload_document':
|
||||||
|
if (!$isEdit) break;
|
||||||
|
|
||||||
|
$docNombre = trim($_POST['doc_nombre'] ?? '');
|
||||||
|
$file = $_FILES['doc_archivo'] ?? null;
|
||||||
|
|
||||||
|
if ($docNombre === '') {
|
||||||
|
$errors[] = 'El nombre del documento es obligatorio.';
|
||||||
|
}
|
||||||
|
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$errors[] = 'Debe seleccionar un archivo válido.';
|
||||||
|
} else {
|
||||||
|
// Validate extension
|
||||||
|
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||||
|
if (!in_array($ext, ALLOWED_EXTENSIONS)) {
|
||||||
|
$errors[] = 'Tipo de archivo no permitido. Permitidos: ' . implode(', ', ALLOWED_EXTENSIONS);
|
||||||
|
}
|
||||||
|
// Validate size
|
||||||
|
if ($file['size'] > MAX_FILE_SIZE) {
|
||||||
|
$errors[] = 'El archivo excede el tamaño máximo de ' . (MAX_FILE_SIZE / 1024 / 1024) . ' MB.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($errors)) {
|
||||||
|
// Create client upload directory
|
||||||
|
$uploadDir = UPLOAD_DIR . 'client_' . $clienteId . '/';
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
mkdir($uploadDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
$safeFilename = preg_replace('/[^a-zA-Z0-9_\-.]/', '_', $file['name']);
|
||||||
|
$filename = time() . '_' . $safeFilename;
|
||||||
|
$destPath = $uploadDir . $filename;
|
||||||
|
|
||||||
|
if (move_uploaded_file($file['tmp_name'], $destPath)) {
|
||||||
|
$rutaArchivo = 'client_' . $clienteId . '/' . $filename;
|
||||||
|
$stmt = $db->prepare("INSERT INTO documentos (cliente_id, nombre, ruta_archivo, tipo) VALUES (?,?,?,?)");
|
||||||
|
$stmt->execute([$clienteId, $docNombre, $rutaArchivo, $ext]);
|
||||||
|
header("Location: cliente-detalle.php?id=$clienteId&doc_saved=1#documentos");
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$errors[] = 'Error al subir el archivo. Intente de nuevo.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ── Delete document ───────────────────────────────────────
|
||||||
|
case 'delete_document':
|
||||||
|
if (!$isEdit) break;
|
||||||
|
|
||||||
|
$docId = (int)($_POST['doc_id'] ?? 0);
|
||||||
|
if ($docId > 0) {
|
||||||
|
// Get file path before deleting
|
||||||
|
$stmt = $db->prepare("SELECT ruta_archivo FROM documentos WHERE id=? AND cliente_id=?");
|
||||||
|
$stmt->execute([$docId, $clienteId]);
|
||||||
|
$doc = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($doc) {
|
||||||
|
// Remove file from disk
|
||||||
|
$filePath = UPLOAD_DIR . $doc['ruta_archivo'];
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
unlink($filePath);
|
||||||
|
}
|
||||||
|
// Remove DB record
|
||||||
|
$stmt = $db->prepare("DELETE FROM documentos WHERE id=? AND cliente_id=?");
|
||||||
|
$stmt->execute([$docId, $clienteId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header("Location: cliente-detalle.php?id=$clienteId&doc_deleted=1#documentos");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handle download (GET) ───────────────────────────────────────
|
||||||
|
if (isset($_GET['download']) && $isEdit) {
|
||||||
|
$docId = (int)$_GET['download'];
|
||||||
|
$stmt = $db->prepare("SELECT nombre, ruta_archivo, tipo FROM documentos WHERE id=? AND cliente_id=?");
|
||||||
|
$stmt->execute([$docId, $clienteId]);
|
||||||
|
$doc = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($doc) {
|
||||||
|
$filePath = UPLOAD_DIR . $doc['ruta_archivo'];
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
$mimeTypes = [
|
||||||
|
'pdf' => 'application/pdf',
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'doc' => 'application/msword',
|
||||||
|
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
];
|
||||||
|
$mime = $mimeTypes[$doc['tipo']] ?? 'application/octet-stream';
|
||||||
|
$downloadName = $doc['nombre'] . '.' . $doc['tipo'];
|
||||||
|
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
|
||||||
|
header('Content-Length: ' . filesize($filePath));
|
||||||
|
header('Cache-Control: no-cache, must-revalidate');
|
||||||
|
readfile($filePath);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// File not found — continue to page with error
|
||||||
|
$errors[] = 'Documento no encontrado.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load client data for edit mode ──────────────────────────────
|
||||||
|
if ($isEdit) {
|
||||||
|
$stmt = $db->prepare("SELECT * FROM clientes WHERE id=?");
|
||||||
|
$stmt->execute([$clienteId]);
|
||||||
|
$cliente = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$cliente) {
|
||||||
|
header('Location: clientes.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch credentials
|
||||||
|
$stmtCred = $db->prepare("SELECT * FROM credenciales WHERE cliente_id=? ORDER BY created_at DESC");
|
||||||
|
$stmtCred->execute([$clienteId]);
|
||||||
|
$credenciales = $stmtCred->fetchAll();
|
||||||
|
|
||||||
|
// Fetch documents
|
||||||
|
$stmtDoc = $db->prepare("SELECT * FROM documentos WHERE cliente_id=? ORDER BY created_at DESC");
|
||||||
|
$stmtDoc->execute([$clienteId]);
|
||||||
|
$documentos = $stmtDoc->fetchAll();
|
||||||
|
|
||||||
|
// Fetch tramites
|
||||||
|
$stmtTram = $db->prepare("SELECT * FROM tramites WHERE cliente_id=? ORDER BY fecha_solicitud DESC, created_at DESC");
|
||||||
|
$stmtTram->execute([$clienteId]);
|
||||||
|
$tramites = $stmtTram->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pre-fill from solicitud if creating new ─────────────────────
|
||||||
|
$fromSolicitud = 0;
|
||||||
|
$prefill = ['nombre' => '', 'telefono' => '', 'email' => '', 'direccion' => '', 'notas' => ''];
|
||||||
|
|
||||||
|
if (!$isEdit && isset($_GET['from_solicitud'])) {
|
||||||
|
$fromSolicitud = (int)$_GET['from_solicitud'];
|
||||||
|
if ($fromSolicitud > 0) {
|
||||||
|
$stmt = $db->prepare("SELECT nombre, telefono, email FROM solicitudes WHERE id=?");
|
||||||
|
$stmt->execute([$fromSolicitud]);
|
||||||
|
$sol = $stmt->fetch();
|
||||||
|
if ($sol) {
|
||||||
|
$prefill['nombre'] = $sol['nombre'];
|
||||||
|
$prefill['telefono'] = $sol['telefono'] ?? '';
|
||||||
|
$prefill['email'] = $sol['email'] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If POST had errors, preserve submitted data
|
||||||
|
if (!empty($errors) && ($_POST['action'] ?? '') === 'save_client') {
|
||||||
|
$prefill['nombre'] = $_POST['nombre'] ?? '';
|
||||||
|
$prefill['telefono'] = $_POST['telefono'] ?? '';
|
||||||
|
$prefill['email'] = $_POST['email'] ?? '';
|
||||||
|
$prefill['direccion'] = $_POST['direccion'] ?? '';
|
||||||
|
$prefill['notas'] = $_POST['notas'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success messages from redirects
|
||||||
|
if (isset($_GET['saved'])) $success = 'Datos del cliente guardados correctamente.';
|
||||||
|
if (isset($_GET['cred_saved'])) $success = 'Credencial agregada correctamente.';
|
||||||
|
if (isset($_GET['cred_deleted'])) $success = 'Credencial eliminada.';
|
||||||
|
if (isset($_GET['doc_saved'])) $success = 'Documento subido correctamente.';
|
||||||
|
if (isset($_GET['doc_deleted'])) $success = 'Documento eliminado.';
|
||||||
|
|
||||||
|
$pageTitle = $isEdit ? 'Cliente: ' . ($cliente['nombre'] ?? '') : 'Nuevo Cliente';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="admin-content__header">
|
||||||
|
<h1>
|
||||||
|
<i class="fas fa-<?= $isEdit ? 'user-edit' : 'user-plus' ?>"></i>
|
||||||
|
<?= $isEdit ? htmlspecialchars($cliente['nombre']) : 'Nuevo Cliente' ?>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
<a href="clientes.php" class="btn btn--sm btn--secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Volver a Clientes
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<div class="alert alert--success alert--dismissible">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span><?= $success ?></span>
|
||||||
|
<button class="alert__close" onclick="this.parentElement.remove()">×</button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($errors)): ?>
|
||||||
|
<div class="alert alert--danger">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<div>
|
||||||
|
<?php foreach ($errors as $err): ?>
|
||||||
|
<div><?= $err ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
SECTION A: Client Data Form
|
||||||
|
============================================================ -->
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem;">
|
||||||
|
<div class="card__header">
|
||||||
|
<h2><i class="fas fa-id-card"></i> Datos del Cliente</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card__body">
|
||||||
|
<form method="POST" action="cliente-detalle.php<?= $isEdit ? '?id=' . $clienteId : '' ?>">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="save_client">
|
||||||
|
<?php if ($fromSolicitud > 0): ?>
|
||||||
|
<input type="hidden" name="from_solicitud" value="<?= $fromSolicitud ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nombre <span class="required">*</span></label>
|
||||||
|
<input type="text" name="nombre" class="form-control" required
|
||||||
|
value="<?= htmlspecialchars($isEdit ? $cliente['nombre'] : $prefill['nombre']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Teléfono</label>
|
||||||
|
<input type="text" name="telefono" class="form-control"
|
||||||
|
value="<?= htmlspecialchars($isEdit ? ($cliente['telefono'] ?? '') : $prefill['telefono']) ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="email" class="form-control"
|
||||||
|
value="<?= htmlspecialchars($isEdit ? ($cliente['email'] ?? '') : $prefill['email']) ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Dirección</label>
|
||||||
|
<textarea name="direccion" class="form-control" rows="2"><?= htmlspecialchars($isEdit ? ($cliente['direccion'] ?? '') : $prefill['direccion']) ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notas</label>
|
||||||
|
<textarea name="notas" class="form-control" rows="3"><?= htmlspecialchars($isEdit ? ($cliente['notas'] ?? '') : $prefill['notas']) ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.75rem; align-items: center;">
|
||||||
|
<button type="submit" class="btn btn--primary">
|
||||||
|
<i class="fas fa-save"></i> <?= $isEdit ? 'Guardar Cambios' : 'Crear Cliente' ?>
|
||||||
|
</button>
|
||||||
|
<?php if ($isEdit): ?>
|
||||||
|
<span style="font-size: var(--admin-font-xs); color: var(--admin-gray-500);">
|
||||||
|
Registrado: <?= date('d/m/Y H:i', strtotime($cliente['created_at'])) ?>
|
||||||
|
<?php if ($cliente['updated_at'] && $cliente['updated_at'] !== $cliente['created_at']): ?>
|
||||||
|
— Actualizado: <?= date('d/m/Y H:i', strtotime($cliente['updated_at'])) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($isEdit): ?>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
SECTION B: Credentials
|
||||||
|
============================================================ -->
|
||||||
|
<div class="card" id="credenciales" style="margin-bottom: 1.5rem;">
|
||||||
|
<div class="card__header">
|
||||||
|
<h2><i class="fas fa-key"></i> Credenciales de Portales</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card__body">
|
||||||
|
<!-- Existing credentials -->
|
||||||
|
<?php if (!empty($credenciales)): ?>
|
||||||
|
<div class="table-responsive" style="margin-bottom: 1.5rem;">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Portal</th>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>Contraseña</th>
|
||||||
|
<th>Notas</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($credenciales as $cred): ?>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?= htmlspecialchars($cred['portal']) ?></strong></td>
|
||||||
|
<td><?= htmlspecialchars($cred['usuario']) ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="cred-password-display">
|
||||||
|
<span class="cred-dots">••••••••</span>
|
||||||
|
<span class="cred-plain" style="display:none;"><?= htmlspecialchars(decryptData($cred['password_enc'])) ?></span>
|
||||||
|
<button type="button" class="btn btn--sm btn--ghost cred-toggle-btn" onclick="toggleCredPassword(this)" title="Mostrar/ocultar contraseña">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars($cred['notas'] ?? '-') ?></td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" style="display:inline;" onsubmit="return confirm('¿Eliminar esta credencial? Esta acción no se puede deshacer.')">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="delete_credential">
|
||||||
|
<input type="hidden" name="cred_id" value="<?= (int)$cred['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn--sm btn--outline-danger" title="Eliminar">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="text-muted" style="margin-bottom: 1rem;">No hay credenciales registradas para este cliente.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Add credential form -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3 class="detail-section__title"><i class="fas fa-plus-circle"></i> Agregar Credencial</h3>
|
||||||
|
<form method="POST" action="cliente-detalle.php?id=<?= $clienteId ?>">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="add_credential">
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Portal <span class="required">*</span></label>
|
||||||
|
<input type="text" name="cred_portal" class="form-control" placeholder="Ej: CEAC, Portal SRE, SAT..." required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Usuario <span class="required">*</span></label>
|
||||||
|
<input type="text" name="cred_usuario" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Contraseña <span class="required">*</span></label>
|
||||||
|
<input type="text" name="cred_password" class="form-control" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notas</label>
|
||||||
|
<input type="text" name="cred_notas" class="form-control" placeholder="Notas adicionales...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--success btn--sm">
|
||||||
|
<i class="fas fa-plus"></i> Agregar Credencial
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
SECTION C: Documents
|
||||||
|
============================================================ -->
|
||||||
|
<div class="card" id="documentos" style="margin-bottom: 1.5rem;">
|
||||||
|
<div class="card__header">
|
||||||
|
<h2><i class="fas fa-folder-open"></i> Documentos</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card__body">
|
||||||
|
<!-- Existing documents -->
|
||||||
|
<?php if (!empty($documentos)): ?>
|
||||||
|
<div class="table-responsive" style="margin-bottom: 1.5rem;">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($documentos as $doc): ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i class="fas fa-file-<?= in_array($doc['tipo'], ['jpg','jpeg','png']) ? 'image' : ($doc['tipo'] === 'pdf' ? 'pdf' : 'word') ?>"></i>
|
||||||
|
<?= htmlspecialchars($doc['nombre']) ?>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge badge--secondary"><?= htmlspecialchars(strtoupper($doc['tipo'] ?? 'N/A')) ?></span></td>
|
||||||
|
<td><?= date('d/m/Y', strtotime($doc['created_at'])) ?></td>
|
||||||
|
<td style="display: flex; gap: 0.5rem;">
|
||||||
|
<a href="cliente-detalle.php?id=<?= $clienteId ?>&download=<?= (int)$doc['id'] ?>" class="btn btn--sm btn--info" title="Descargar">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<form method="POST" style="display:inline;" onsubmit="return confirm('¿Eliminar este documento? El archivo se borrará permanentemente.')">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="delete_document">
|
||||||
|
<input type="hidden" name="doc_id" value="<?= (int)$doc['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn--sm btn--outline-danger" title="Eliminar">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="text-muted" style="margin-bottom: 1rem;">No hay documentos subidos para este cliente.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Upload document form -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3 class="detail-section__title"><i class="fas fa-upload"></i> Subir Documento</h3>
|
||||||
|
<form method="POST" action="cliente-detalle.php?id=<?= $clienteId ?>" enctype="multipart/form-data">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="upload_document">
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nombre del Documento <span class="required">*</span></label>
|
||||||
|
<input type="text" name="doc_nombre" class="form-control" placeholder="Ej: Pasaporte, INE, Comprobante..." required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Archivo <span class="required">*</span></label>
|
||||||
|
<input type="file" name="doc_archivo" class="form-control" accept=".pdf,.jpg,.jpeg,.png,.doc,.docx" required>
|
||||||
|
<span class="form-text">Máx. <?= MAX_FILE_SIZE / 1024 / 1024 ?> MB. Formatos: PDF, JPG, PNG, DOC, DOCX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--success btn--sm">
|
||||||
|
<i class="fas fa-upload"></i> Subir Documento
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
SECTION D: Process History (Tramites)
|
||||||
|
============================================================ -->
|
||||||
|
<div class="card" id="tramites" style="margin-bottom: 1.5rem;">
|
||||||
|
<div class="card__header">
|
||||||
|
<h2><i class="fas fa-file-alt"></i> Historial de Trámites</h2>
|
||||||
|
<a href="tramite-detalle.php?cliente_id=<?= $clienteId ?>" class="btn btn--sm btn--primary">
|
||||||
|
<i class="fas fa-plus"></i> Nuevo Trámite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card__body">
|
||||||
|
<?php if (!empty($tramites)): ?>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Fecha Solicitud</th>
|
||||||
|
<th>Fecha Cita</th>
|
||||||
|
<th>Precio</th>
|
||||||
|
<th>Acción</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($tramites as $t): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars($tipoLabels[$t['tipo']] ?? $t['tipo']) ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge--<?= htmlspecialchars($t['estado']) ?>">
|
||||||
|
<?= $estadoLabels[$t['estado']] ?? htmlspecialchars($t['estado']) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><?= $t['fecha_solicitud'] ? date('d/m/Y', strtotime($t['fecha_solicitud'])) : '-' ?></td>
|
||||||
|
<td><?= $t['fecha_cita'] ? date('d/m/Y', strtotime($t['fecha_cita'])) : '-' ?></td>
|
||||||
|
<td><?= $t['precio'] ? '$' . number_format($t['precio'], 2) : '-' ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="tramite-detalle.php?id=<?= (int)$t['id'] ?>" class="btn btn--sm btn--primary">
|
||||||
|
<i class="fas fa-eye"></i> Ver
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-empty">
|
||||||
|
<i class="fas fa-file-alt"></i>
|
||||||
|
<p>No hay trámites registrados para este cliente.</p>
|
||||||
|
<a href="tramite-detalle.php?cliente_id=<?= $clienteId ?>" class="btn btn--sm btn--outline">Crear primer trámite</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; /* end isEdit sections */ ?>
|
||||||
|
|
||||||
|
<!-- JavaScript for credential password toggle -->
|
||||||
|
<script>
|
||||||
|
function toggleCredPassword(btn) {
|
||||||
|
var container = btn.closest('.cred-password-display');
|
||||||
|
var dots = container.querySelector('.cred-dots');
|
||||||
|
var plain = container.querySelector('.cred-plain');
|
||||||
|
var icon = btn.querySelector('i');
|
||||||
|
|
||||||
|
if (plain.style.display === 'none') {
|
||||||
|
dots.style.display = 'none';
|
||||||
|
plain.style.display = 'inline';
|
||||||
|
icon.classList.remove('fa-eye');
|
||||||
|
icon.classList.add('fa-eye-slash');
|
||||||
|
btn.title = 'Ocultar contrase\u00f1a';
|
||||||
|
} else {
|
||||||
|
dots.style.display = 'inline';
|
||||||
|
plain.style.display = 'none';
|
||||||
|
icon.classList.remove('fa-eye-slash');
|
||||||
|
icon.classList.add('fa-eye');
|
||||||
|
btn.title = 'Mostrar contrase\u00f1a';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cred-password-display {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.cred-dots {
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--admin-gray-500);
|
||||||
|
font-size: var(--admin-font-sm);
|
||||||
|
}
|
||||||
|
.cred-plain {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: var(--admin-font-sm);
|
||||||
|
background: var(--admin-gray-100);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: var(--admin-radius-sm);
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<?php require_once __DIR__ . '/includes/admin-footer.php'; ?>
|
||||||
152
admin/clientes.php
Normal file
152
admin/clientes.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
$pageTitle = 'Clientes';
|
||||||
|
require_once __DIR__ . '/../includes/db.php';
|
||||||
|
require_once __DIR__ . '/includes/admin-header.php';
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
// Search & Pagination
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$perPage = 15;
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
// Build query
|
||||||
|
$where = '';
|
||||||
|
$params = [];
|
||||||
|
if ($q !== '') {
|
||||||
|
$where = 'WHERE c.nombre LIKE ? OR c.telefono LIKE ? OR c.email LIKE ?';
|
||||||
|
$like = '%' . $q . '%';
|
||||||
|
$params = [$like, $like, $like];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
$countSql = "SELECT COUNT(DISTINCT c.id) FROM clientes c $where";
|
||||||
|
$countStmt = $db->prepare($countSql);
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$totalClientes = $countStmt->fetchColumn();
|
||||||
|
$totalPages = max(1, (int)ceil($totalClientes / $perPage));
|
||||||
|
|
||||||
|
// Fetch clients with tramites count
|
||||||
|
$sql = "SELECT c.*, COUNT(t.id) AS total_tramites
|
||||||
|
FROM clientes c
|
||||||
|
LEFT JOIN tramites t ON c.id = t.cliente_id
|
||||||
|
$where
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
LIMIT $perPage OFFSET $offset";
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$clientes = $stmt->fetchAll();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="admin-content__header">
|
||||||
|
<h1><i class="fas fa-users"></i> Clientes</h1>
|
||||||
|
<p>Gestión de clientes registrados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar: Search + New Client -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar__left">
|
||||||
|
<form method="GET" class="search-bar" action="clientes.php">
|
||||||
|
<i class="fas fa-search search-bar__icon"></i>
|
||||||
|
<input type="text" name="q" class="search-bar__input" placeholder="Buscar por nombre, teléfono o email..." value="<?= htmlspecialchars($q) ?>">
|
||||||
|
</form>
|
||||||
|
<?php if ($q !== ''): ?>
|
||||||
|
<a href="clientes.php" class="btn btn--sm btn--secondary">
|
||||||
|
<i class="fas fa-times"></i> Limpiar
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar__right">
|
||||||
|
<a href="cliente-detalle.php" class="btn btn--primary">
|
||||||
|
<i class="fas fa-plus"></i> Nuevo Cliente
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clients Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card__body">
|
||||||
|
<?php if (empty($clientes)): ?>
|
||||||
|
<div class="table-empty">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
<p><?= $q !== '' ? 'No se encontraron clientes con esa búsqueda.' : 'No hay clientes registrados aún.' ?></p>
|
||||||
|
<?php if ($q !== ''): ?>
|
||||||
|
<a href="clientes.php" class="btn btn--sm btn--outline">Ver todos</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Teléfono</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Trámites</th>
|
||||||
|
<th>Fecha Registro</th>
|
||||||
|
<th>Acción</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($clientes as $c): ?>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?= htmlspecialchars($c['nombre']) ?></strong></td>
|
||||||
|
<td><?= htmlspecialchars($c['telefono'] ?: '-') ?></td>
|
||||||
|
<td><?= htmlspecialchars($c['email'] ?: '-') ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge--info"><?= (int)$c['total_tramites'] ?></span>
|
||||||
|
</td>
|
||||||
|
<td><?= date('d/m/Y', strtotime($c['created_at'])) ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="cliente-detalle.php?id=<?= (int)$c['id'] ?>" class="btn btn--sm btn--primary">
|
||||||
|
<i class="fas fa-eye"></i> Ver
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<?php if ($totalPages > 1): ?>
|
||||||
|
<div class="pagination">
|
||||||
|
<?php
|
||||||
|
$queryParams = $q !== '' ? '&q=' . urlencode($q) : '';
|
||||||
|
?>
|
||||||
|
<a href="?page=1<?= $queryParams ?>" class="pagination__link <?= $page <= 1 ? 'pagination__link--disabled' : '' ?>">
|
||||||
|
<i class="fas fa-angle-double-left"></i>
|
||||||
|
</a>
|
||||||
|
<a href="?page=<?= $page - 1 ?><?= $queryParams ?>" class="pagination__link <?= $page <= 1 ? 'pagination__link--disabled' : '' ?>">
|
||||||
|
<i class="fas fa-angle-left"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$start = max(1, $page - 2);
|
||||||
|
$end = min($totalPages, $page + 2);
|
||||||
|
for ($i = $start; $i <= $end; $i++):
|
||||||
|
?>
|
||||||
|
<a href="?page=<?= $i ?><?= $queryParams ?>" class="pagination__link <?= $i === $page ? 'pagination__link--active' : '' ?>">
|
||||||
|
<?= $i ?>
|
||||||
|
</a>
|
||||||
|
<?php endfor; ?>
|
||||||
|
|
||||||
|
<a href="?page=<?= $page + 1 ?><?= $queryParams ?>" class="pagination__link <?= $page >= $totalPages ? 'pagination__link--disabled' : '' ?>">
|
||||||
|
<i class="fas fa-angle-right"></i>
|
||||||
|
</a>
|
||||||
|
<a href="?page=<?= $totalPages ?><?= $queryParams ?>" class="pagination__link <?= $page >= $totalPages ? 'pagination__link--disabled' : '' ?>">
|
||||||
|
<i class="fas fa-angle-double-right"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span class="pagination__info">
|
||||||
|
Página <?= $page ?> de <?= $totalPages ?> (<?= $totalClientes ?> clientes)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php require_once __DIR__ . '/includes/admin-footer.php'; ?>
|
||||||
Reference in New Issue
Block a user