Merge branch 'main' into feat/standardize-ui-cards

This commit is contained in:
josedario87
2025-05-31 02:10:32 -06:00
committed by GitHub
20 changed files with 1747 additions and 791 deletions

View File

@@ -1,9 +1,14 @@
# Vue 3 + Vite
# Project UI (Vue 3 + Vite)
This project frontend is built with Vue 3 and Vite. It provides a user interface for managing various aspects of the application.
## Development
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
## Standardized Card Components
This section documents the standardized card components used throughout the UI for modules like Asistencias, Empleados, Planillas, and Tareas. These components have been refactored for a consistent structure, styling approach, and user experience.
@@ -97,3 +102,4 @@ const handleEditAsistencia = (asistenciaId) => {
</script>
```
The specific prop name matches the module (e.g., `asistencia` for `cardAsistencia`, `employee` for `cardEmpleado`, `planilla` for `cardPlanilla`, `tarea` for `cardTarea`).

View File

@@ -1,31 +1,39 @@
<template>
<div class="tabla-asistencias-container">
<table class="tabla-asistencias">
<thead>
<div class="p-4 sm:p-6 rounded-lg overflow-x-auto" :style="{ backgroundColor: ui.tableBgColorAsistencias }">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 dark:bg-slate-700">
<tr>
<th>ID</th>
<th>Empleado ID</th>
<th>Entrada</th>
<th>Salida</th>
<th>Estado</th>
<th>Observación</th>
<th>Acciones</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">ID</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Empleado ID</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Entrada</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Salida</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Estado</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Observación</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Acciones</th>
</tr>
</thead>
<tbody>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
<tr v-if="!asistencias || asistencias.length === 0">
<td :colspan="7" style="text-align: center;">No hay asistencias para mostrar.</td>
<td colspan="7" class="px-6 py-10 text-center text-gray-500 dark:text-slate-400 text-lg">No hay asistencias para mostrar.</td>
</tr>
<tr v-for="asistencia in asistencias" :key="asistencia.id">
<td>{{ asistencia.id }}</td>
<td>{{ asistencia.empleado_id }}</td>
<td>{{ formatDateTime(asistencia.entrada) }}</td>
<td>{{ asistencia.salida ? formatDateTime(asistencia.salida) : 'N/A' }}</td>
<td><span :class="`estado-${asistencia.estado?.toLowerCase().replace(/\s+/g, '-')}`">{{ asistencia.estado }}</span></td>
<td :title="asistencia.observacion">{{ truncateText(asistencia.observacion, 50) }}</td>
<td>
<button @click="editAsistencia(asistencia.id)" class="action-button edit-button">Editar</button>
<button @click="confirmDeleteAsistencia(asistencia)" class="action-button delete-button">Eliminar</button>
<tr v-for="asistencia in asistencias" :key="asistencia.id" class="transition-colors duration-150 ease-in-out hover:bg-[var(--accent-color-asistencias)]/10 dark:hover:bg-[var(--accent-color-asistencias)]/20">
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ asistencia.id }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ asistencia.empleado_id }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ formatDateTime(asistencia.entrada) }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ asistencia.salida ? formatDateTime(asistencia.salida) : 'N/A' }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
<span :class="['px-2.5 py-0.5 rounded-full text-xs font-semibold', getStatusClass(asistencia.estado)]">{{ asistencia.estado }}</span>
</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 text-sm text-gray-700 dark:text-slate-300 max-w-xs whitespace-nowrap" :title="asistencia.observacion">{{ truncateText(asistencia.observacion, 50) }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
<div class="flex items-center space-x-2">
<button @click="editAsistencia(asistencia.id)" class="p-1.5 sm:p-2 rounded-md transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-600/20 focus:ring-blue-500 dark:focus:ring-blue-400" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /></svg>
</button>
<button @click="confirmDeleteAsistencia(asistencia)" class="p-1.5 sm:p-2 rounded-md transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 hover:bg-red-100 dark:hover:bg-red-600/20 focus:ring-red-500 dark:focus:ring-red-400" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12.56 0c1.153 0 2.24.03 3.22.077m3.22-.077L10.879 3.28a2.25 2.25 0 012.244-2.077h.093c.956 0 1.853.543 2.244 2.077L14.74 5.79m-4.858 0l-2.828-2.828A1.875 1.875 0 016.188 2.188l2.828 2.828m6.912 0l2.828-2.828a1.875 1.875 0 00-2.652-2.652L12 5.79M9.26 9h5.48L9.26 9z" /></svg>
</button>
</div>
</td>
</tr>
</tbody>
@@ -36,6 +44,10 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import { useAsistenciasStore } from '../../stores/useAsistencias';
import { formatDateTime, truncateText, getStatusClass } from '../../utils/formatters.js';
import { useUi } from '../../stores/useUi.js';
const ui = useUi();
const props = defineProps({
asistencias: {
@@ -49,24 +61,6 @@ const emit = defineEmits(['edit']);
const asistenciasStore = useAsistenciasStore();
const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return 'N/A';
const date = new Date(dateTimeString);
// Using UTC methods to ensure consistency if dates are stored in UTC
const day = String(date.getUTCDate()).padStart(2, '0');
const month = String(date.getUTCMonth() + 1).padStart(2, '0'); // Months are 0-indexed
const year = date.getUTCFullYear();
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
};
const truncateText = (text, maxLength) => {
if (!text) return 'N/A';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
const editAsistencia = (id) => {
emit('edit', id);
};
@@ -87,79 +81,3 @@ const deleteAsistenciaInternal = async (id) => {
}
};
</script>
<style scoped>
.tabla-asistencias-container {
overflow-x: auto;
}
.tabla-asistencias {
/* Apply module-specific background color */
background-color: var(--table-bg-color-asistencias);
width: 100%;
border-collapse: collapse;
margin-top: 1em;
font-size: 0.9em;
}
.tabla-asistencias th,
.tabla-asistencias td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
vertical-align: middle; /* Good for table cells */
}
.tabla-asistencias th {
background-color: #f4f6f8; /* Light grey for header */
font-weight: 600; /* Bolder text for header */
color: #333;
}
.tabla-asistencias tr:nth-child(even) {
background-color: #f9fafb; /* Very light alternating row color */
}
.tabla-asistencias tr:hover {
background-color: #f0f0f0; /* Hover effect */
}
.action-button {
padding: 6px 10px;
margin-right: 6px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.action-button:hover {
transform: translateY(-1px); /* Slight lift effect */
}
.edit-button {
background-color: var(--accent-color-asistencias);
color: white;
}
.edit-button:hover {
filter: brightness(0.9);
}
.delete-button {
background-color: var(--warning-color); /* Use warning color for delete */
color: white;
}
.delete-button:hover {
filter: brightness(0.9);
}
/* Estado specific styling (using text color for tables is often cleaner) */
.estado-pendiente { color: #ffc107; font-weight: bold; } /* Amber */
.estado-presente,
.estado-confirmada { color: #28a745; font-weight: bold; } /* Green */
.estado-ausente { color: #dc3545; font-weight: bold; } /* Red */
.estado-justificada { color: #17a2b8; font-weight: bold; } /* Info Blue */
.estado-cancelada,
.estado-anulada { color: #6c757d; font-weight: bold; } /* Gray */
/* If you prefer background colors like in cards, copy those styles here, but they can be visually heavy in tables. */
</style>

View File

@@ -1,83 +1,72 @@
<template>
<div class="overflow-x-auto bg-white shadow-md rounded-lg p-4">
<table class="min-w-full divide-y divide-gray-200 table-empleados">
<thead class="bg-gray-50">
<div class="p-4 sm:p-6 rounded-lg overflow-x-auto" :style="{ backgroundColor: ui.tableBgColorEmpleados }">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 dark:bg-slate-700">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">
Avatar
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">
Nombre
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">
Cédula
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">
Teléfono
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">
Ubicación
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">
ID CIAT
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
<tr v-if="!employees || employees.length === 0">
<td colspan="7" class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
<td colspan="7" class="px-6 py-10 text-center text-gray-500 dark:text-slate-400 text-lg">
No hay empleados para mostrar.
</td>
</tr>
<tr v-for="employee in employees" :key="employee.id" class="hover:bg-gray-100 transition-colors duration-150 ease-in-out">
<td class="px-6 py-4 whitespace-nowrap">
<tr v-for="employee in employees" :key="employee.id" class="transition-colors duration-150 ease-in-out hover:bg-[var(--accent-color-empleados)]/10 dark:hover:bg-[var(--accent-color-empleados)]/20">
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap">
<img
:src="employee.avatar_url || 'https://via.placeholder.com/40'"
alt="Avatar"
class="w-10 h-10 rounded-full object-cover border border-gray-300 shadow-sm"
/>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-semibold text-gray-900">{{ employee.name }}</div>
<div v-if="employee.grupo_estudio" class="text-xs text-gray-500">
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm">
<div class="text-sm font-semibold text-gray-800 dark:text-slate-200">{{ employee.name }}</div>
<div v-if="employee.grupo_estudio" class="text-xs text-gray-500 dark:text-slate-400">
Grupo: {{ employee.grupo_estudio }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
{{ employee.cedula }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
{{ employee.telefono || '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
{{ employee.ubicacion }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
{{ employee.idciat || '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button
@click="handleEdit(employee.id)"
class="edit-action-button p-1 rounded-md transition-all duration-150 ease-in-out"
title="Editar Empleado"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
</svg>
</button>
<button
@click="handleViewDetails(employee.id)"
class="text-green-600 hover:text-green-800 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 p-1 rounded-md hover:bg-green-100 transition-all duration-150 ease-in-out"
title="Ver Detalles del Empleado"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</button>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm font-medium">
<div class="flex items-center space-x-2">
<button @click="handleEdit(employee.id)" class="p-1.5 sm:p-2 rounded-md transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-600/20 focus:ring-blue-500 dark:focus:ring-blue-400" title="Editar Empleado">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /></svg>
</button>
<button @click="handleViewDetails(employee.id)" class="p-1.5 sm:p-2 rounded-md transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 hover:bg-green-100 dark:hover:bg-green-600/20 focus:ring-green-500 dark:focus:ring-green-400" title="Ver Detalles">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg>
</button>
</div>
</td>
</tr>
</tbody>
@@ -88,6 +77,9 @@
<script setup lang="ts">
import { PropType } from 'vue'
import { useRouter } from 'vue-router'
import { useUi } from '../../stores/useUi.js';
const ui = useUi();
// Interface for Employee object structure, aligning with prisma model (excluding sensitive or large fields for table view)
interface Employee {
@@ -123,57 +115,17 @@ const handleViewDetails = (employeeId: string | number) => {
</script>
<style scoped>
/* Enhancing table aesthetics and interactivity */
.min-w-full {
border-collapse: separate; /* Allows for rounded corners on table if desired, or better spacing */
border-spacing: 0;
}
th {
font-weight: 600; /* semibold for headers */
color: #4b5563; /* gray-600 for header text */
}
td {
color: #374151; /* gray-700 for cell text */
}
/* Scoped styles can be minimized or removed if Tailwind covers all needs */
.rounded-full {
object-fit: cover; /* Ensures avatar images are displayed nicely within their circular bounds */
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); /* Subtle shadow for avatars */
}
/* Action button icon styling */
.edit-action-button {
color: var(--accent-color-empleados);
}
.edit-action-button:hover {
background-color: var(--accent-color-empleados);
color: white; /* Assuming accent color is dark enough for white text */
}
.edit-action-button:focus {
outline: none;
box-shadow: 0 0 0 2px var(--background-color), 0 0 0 4px var(--accent-color-empleados);
object-fit: cover; /* Ensures avatar images are displayed nicely */
}
/* Optional: Keep icon transition if not handled by Tailwind's transition utilities on the button */
button svg {
transition: transform 0.15s ease-in-out;
}
button:hover svg {
transform: scale(1.15); /* Slightly larger pop on hover for icons */
}
/* Ensure consistent padding and alignment */
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
/* Adding a subtle border to the table container itself */
.overflow-x-auto {
border: 1px solid #e5e7eb; /* Tailwind's gray-200 */
}
table.table-empleados {
background-color: var(--table-bg-color-empleados);
transform: scale(1.1); /* Adjusted scale for a subtler effect */
}
</style>

View File

@@ -1,33 +1,41 @@
<template>
<div class="tabla-planillas-container">
<table class="tabla-planillas">
<thead>
<div class="p-4 sm:p-6 rounded-lg overflow-x-auto" :style="{ backgroundColor: ui.tableBgColorPlanillas }">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 dark:bg-slate-700">
<tr>
<th>ID</th>
<th>Título</th>
<th>Empleado ID</th>
<th>Fecha Desde</th>
<th>Fecha Hasta</th>
<th>Total</th>
<th>Estado</th>
<th>Acciones</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">ID</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Título</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Empleado ID</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Fecha Desde</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Fecha Hasta</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Total</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Estado</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Acciones</th>
</tr>
</thead>
<tbody>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
<tr v-if="planillas && planillas.length === 0">
<td :colspan="8" style="text-align: center;">No hay planillas para mostrar.</td>
<td colspan="8" class="px-6 py-10 text-center text-gray-500 dark:text-slate-400 text-lg">No hay planillas para mostrar.</td>
</tr>
<tr v-for="planilla in planillas" :key="planilla.id">
<td>{{ planilla.id }}</td>
<td>{{ planilla.titulo }}</td>
<td>{{ planilla.empleado_id }}</td>
<td>{{ formatDate(planilla.fecha_desde) }}</td>
<td>{{ formatDate(planilla.fecha_hasta) }}</td>
<td>{{ formatCurrency(planilla.total) }}</td>
<td><span :class="`estado-${planilla.estado?.toLowerCase()}`">{{ planilla.estado }}</span></td>
<td>
<button @click="editPlanilla(planilla.id)" class="action-button edit-button">Editar</button>
<button @click="confirmDeletePlanilla(planilla)" class="action-button delete-button">Eliminar</button>
<tr v-for="planilla in planillas" :key="planilla.id" class="transition-colors duration-150 ease-in-out hover:bg-[var(--accent-color-planillas)]/10 dark:hover:bg-[var(--accent-color-planillas)]/20">
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ planilla.id }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ planilla.titulo }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ planilla.empleado_id }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ formatDate(planilla.fecha_desde) }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ formatDate(planilla.fecha_hasta) }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ formatCurrency(planilla.total) }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
<span :class="['px-2.5 py-0.5 rounded-full text-xs font-semibold', getStatusClass(planilla.estado)]">{{ planilla.estado }}</span>
</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
<div class="flex items-center space-x-2">
<button @click="editPlanilla(planilla.id)" class="p-1.5 sm:p-2 rounded-md transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-600/20 focus:ring-blue-500 dark:focus:ring-blue-400" title="Editar">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /></svg>
</button>
<button @click="confirmDeletePlanilla(planilla)" class="p-1.5 sm:p-2 rounded-md transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 hover:bg-red-100 dark:hover:bg-red-600/20 focus:ring-red-500 dark:focus:ring-red-400" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12.56 0c1.153 0 2.24.03 3.22.077m3.22-.077L10.879 3.28a2.25 2.25 0 012.244-2.077h.093c.956 0 1.853.543 2.244 2.077L14.74 5.79m-4.858 0l-2.828-2.828A1.875 1.875 0 016.188 2.188l2.828 2.828m6.912 0l2.828-2.828a1.875 1.875 0 00-2.652-2.652L12 5.79M9.26 9h5.48L9.26 9z" /></svg>
</button>
</div>
</td>
</tr>
</tbody>
@@ -38,6 +46,10 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import { usePlanillasStore } from '../../stores/usePlanillas';
import { formatDate, formatCurrency, getStatusClass } from '../../utils/formatters.js';
import { useUi } from '../../stores/useUi.js';
const ui = useUi();
const props = defineProps({
planillas: {
@@ -51,24 +63,6 @@ const emit = defineEmits(['edit']); // Removed 'delete' as it's handled internal
const planillasStore = usePlanillasStore();
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', {
year: 'numeric',
month: '2-digit', // Changed to 2-digit for table compactness
day: '2-digit', // Changed to 2-digit for table compactness
});
};
const formatCurrency = (value) => {
if (value == null) return 'N/A';
return Number(value).toLocaleString('es-PY', {
style: 'currency',
currency: 'PYG',
});
};
const editPlanilla = (id) => {
emit('edit', id);
};
@@ -90,79 +84,3 @@ const deletePlanillaInternal = async (id) => {
}
};
</script>
<style scoped>
.tabla-planillas-container {
overflow-x: auto; /* Allows table to be scrolled horizontally if needed */
}
.tabla-planillas {
background-color: var(--table-bg-color-planillas); /* Added */
width: 100%;
border-collapse: collapse;
margin-top: 1em;
font-size: 0.9em;
}
.tabla-planillas th,
.tabla-planillas td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.tabla-planillas th {
background-color: #f2f2f2;
font-weight: bold;
}
.tabla-planillas tr:nth-child(even) {
background-color: #f9f9f9;
}
.tabla-planillas tr:hover {
background-color: #f1f1f1;
}
.action-button {
padding: 5px 10px;
margin-right: 5px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.edit-button {
background-color: var(--accent-color-planillas);
color: white;
}
.edit-button:hover {
filter: brightness(0.9);
}
.delete-button {
background-color: var(--warning-color); /* Using warning color for delete */
color: white;
}
.delete-button:hover {
filter: brightness(0.9);
}
/* Status styling (similar to cardPlanilla) */
.estado-pagado {
color: green;
font-weight: bold;
}
.estado-pendiente {
color: orange;
font-weight: bold;
}
.estado-anulado {
color: red;
font-weight: bold;
/* text-decoration: line-through; */ /* Optional for table view */
}
</style>

View File

@@ -1,35 +1,74 @@
<template>
<div class="tabla-tareas-container">
<table class="tabla-tareas">
<thead>
<div
class="p-4 sm:p-6 rounded-lg overflow-x-auto"
:style="{ '--bg-tareas': ui.tableBgColorTareas }"
>
<table
class="min-w-full divide-y divide-[var(--accent-color-tareas)]"
:style="{ backgroundColor: ui.tableBgColorTareas }"
>
<thead
class="divide-y divide-gray-200 dark:divide-slate-700"
:style="{ backgroundColor: ui.tableBgColorTareas }"
>
<tr>
<th>ID</th>
<th>Título</th>
<th>Empleado ID</th>
<th>Fecha</th>
<th>Estado</th>
<th>Tipo</th>
<th>Precio</th>
<th>Planilla ID</th>
<th>Acciones</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">ID</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Título</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Empleado ID</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Fecha</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Estado</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Tipo</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Precio</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Planilla ID</th>
<th class="px-4 py-3 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wider">Acciones</th>
</tr>
</thead>
<tbody>
<tbody
class="divide-y divide-[var(--accent-color-tareas)]"
:style="{ backgroundColor: ui.tableBgColorTareas }"
>
<tr v-if="!tareas || tareas.length === 0">
<td :colspan="9" style="text-align: center;">No hay tareas para mostrar.</td>
<td colspan="9" class="px-6 py-10 text-center text-gray-500 dark:text-slate-400 text-lg">
No hay tareas para mostrar.
</td>
</tr>
<tr v-for="tarea in tareas" :key="tarea.id">
<td>{{ tarea.id }}</td>
<td>{{ tarea.titulo }}</td>
<td>{{ tarea.empleado_id }}</td>
<td>{{ formatDate(tarea.fecha) }}</td>
<td><span :class="`estado-${tarea.estado?.toLowerCase().replace(/\s+/g, '-')}`">{{ tarea.estado }}</span></td>
<td>{{ tarea.tipo || 'N/A' }}</td>
<td>{{ tarea.precio != null ? formatCurrency(tarea.precio) : 'N/A' }}</td>
<td>{{ tarea.planilla_id || 'N/A' }}</td>
<td>
<button @click="editTarea(tarea.id)" class="action-button edit-button">Editar</button>
<button @click="confirmDeleteTarea(tarea)" class="action-button delete-button">Eliminar</button>
<tr
v-for="tarea in tareas"
:key="tarea.id"
class="bg-[var(--bg-tareas)] transition-colors duration-150 ease-in-out hover:bg-[var(--accent-color-tareas)]/10 dark:hover:bg-[var(--accent-color-tareas)]/20"
>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ tarea.id }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ tarea.titulo }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ tarea.empleado_id }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ formatDate(tarea.fecha) }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
<span :class="['px-2.5 py-0.5 rounded-full text-xs font-semibold', getStatusClass(tarea.estado)]">
{{ tarea.estado }}
</span>
</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ tarea.tipo || 'N/A' }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ tarea.precio != null ? formatCurrency(tarea.precio) : 'N/A' }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">{{ tarea.planilla_id || 'N/A' }}</td>
<td class="px-4 py-3 sm:px-6 sm:py-4 whitespace-nowrap text-sm text-gray-700 dark:text-slate-300">
<div class="flex items-center space-x-2">
<button
@click="editTarea(tarea.id)"
class="p-1.5 sm:p-2 rounded-md transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-600/20 focus:ring-blue-500 dark:focus:ring-blue-400"
title="Editar"
>
<!-- ícono editar -->
</button>
<button
@click="confirmDeleteTarea(tarea)"
class="p-1.5 sm:p-2 rounded-md transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 hover:bg-red-100 dark:hover:bg-red-600/20 focus:ring-red-500 dark:focus:ring-red-400"
title="Eliminar"
>
<!-- ícono eliminar -->
</button>
</div>
</td>
</tr>
</tbody>
@@ -38,131 +77,33 @@
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { useTareasStore } from '../../stores/useTareas';
import { defineProps, defineEmits } from 'vue'
import { useTareasStore } from '@/stores/useTareas'
import { formatDate, formatCurrency, getStatusClass } from '@/utils/formatters'
import { useUi } from '@/stores/useUi'
const props = defineProps({
tareas: {
type: Array,
required: true,
default: () => [],
},
});
const ui = useUi()
const emit = defineEmits(['edit']);
defineProps({
tareas: { type: Array, required: true, default: () => [] },
})
const tareasStore = useTareasStore();
const emit = defineEmits(['edit'])
const tareasStore = useTareasStore()
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
// Assuming dateString might be a full ISO string, ensure it's handled correctly by Date constructor
const date = new Date(dateString);
const day = String(date.getUTCDate()).padStart(2, '0'); // Use getUTCDate for consistency if dates are stored in UTC
const month = String(date.getUTCMonth() + 1).padStart(2, '0'); // Months are 0-indexed
const year = date.getUTCFullYear();
return `${day}/${month}/${year}`;
};
const formatCurrency = (value) => {
if (value == null) return 'N/A';
return Number(value).toLocaleString('es-PY', { // Paraguayan Guarani
style: 'currency',
currency: 'PYG',
});
};
const editTarea = (id) => {
emit('edit', id);
};
const editTarea = (id) => emit('edit', id)
const confirmDeleteTarea = (tarea) => {
if (confirm(`¿Está seguro de que desea eliminar la tarea "${tarea.titulo}" (ID: ${tarea.id})?`)) {
deleteTareaInternal(tarea.id);
}
};
if (confirm(`¿Está seguro de que desea eliminar la tarea "${tarea.titulo}" (ID: ${tarea.id})?`))
deleteTareaInternal(tarea.id)
}
const deleteTareaInternal = async (id) => {
try {
await tareasStore.deleteTarea(id);
// Optional: Show success notification or emit 'deleted' event
} catch (error) {
console.error(`Error deleting tarea with id ${id}:`, error);
alert('Ocurrió un error al eliminar la tarea.');
// Optional: Show error notification
await tareasStore.deleteTarea(id)
} catch (e) {
console.error(e)
alert('Ocurrió un error al eliminar la tarea.')
}
};
}
</script>
<style scoped>
.tabla-tareas-container {
overflow-x: auto;
}
.tabla-tareas {
background-color: var(--table-bg-color-tareas); /* Added */
width: 100%;
border-collapse: collapse;
margin-top: 1em;
font-size: 0.9em;
}
.tabla-tareas th,
.tabla-tareas td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
vertical-align: middle;
}
.tabla-tareas th {
background-color: #f4f6f8;
font-weight: 600;
color: #333;
}
.tabla-tareas tr:nth-child(even) {
background-color: #f9fafb;
}
.tabla-tareas tr:hover {
background-color: #f0f0f0;
}
.action-button {
padding: 6px 10px;
margin-right: 6px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.action-button:hover {
transform: translateY(-1px);
}
.edit-button {
background-color: var(--accent-color-tareas);
color: white;
}
.edit-button:hover {
filter: brightness(0.9); /* Standard hover effect */
}
.delete-button {
background-color: #dc3545; /* Red */
color: white;
}
.delete-button:hover {
background-color: #c82333;
}
/* Status styling (consistent with cardTarea) */
.estado-pendiente { color: #ff9800; font-weight: bold; } /* Orange */
.estado-realizada,
.estado-completada, /* Synonyms */
.estado-hecho { color: #4caf50; font-weight: bold; } /* Green */
.estado-en-progreso { color: #2196f3; font-weight: bold; } /* Blue */
.estado-anulada,
.estado-cancelada { color: #f44336; font-weight: bold; } /* Red */
</style>

View File

@@ -21,6 +21,15 @@ watch(route, v => (activePath.value = v.path))
// clases dinámicas p/ mostrar / ocultar barra
const sidebarClasses = computed(() => ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full')
const handleLinkClick = () => {
// Close sidebar if desktopNavbarPersistent is false or if it's mobile view (width < 768px)
// Assuming 768px is the 'md' breakpoint.
if (!ui.desktopNavbarPersistent || window.innerWidth < 768) {
ui.closeSidebar()
}
// Otherwise, (desktopNavbarPersistent is true AND width >= 768px), do nothing to keep sidebar open.
}
</script>
<template>
@@ -53,7 +62,7 @@ const sidebarClasses = computed(() => ui.sidebarOpen ? 'translate-x-0' : '-trans
:to="l.to"
class="nav-link flex items-center gap-3 w-full px-3 py-2 rounded-md font-medium transition group"
:class="activePath.startsWith(l.to) ? 'active' : ''"
@click="ui.closeSidebar()"
@click="handleLinkClick"
>
<span class="text-lg" aria-hidden="true">{{ l.icon }}</span>
<span class="truncate">{{ l.label }}</span>

View File

@@ -48,10 +48,16 @@ describe('useUi Store', () => {
expect(store.accentColorTareas).toBe('#4CAF50')
expect(store.accentColorPlanillas).toBe('#FF9800')
expect(store.accentColorAsistencias).toBe('#E91E63')
expect(localStorageMock.getItem).toHaveBeenCalledWith(APPEARANCE_STORAGE_KEY)
})
it('loads settings from localStorage including new accent colors if present', () => {
expect(store.accentColorConfiguracion).toBe('#607D8B')
// Check new default view defaults
expect(store.defaultViewEmpleados).toBe('table')
expect(store.defaultViewTareas).toBe('table')
expect(store.defaultViewPlanillas).toBe('table')
expect(store.defaultViewAsistencias).toBe('table')
expect(store.defaultViewConfiguracion).toBe('table')
// Check other new defaults
expect(store.tableBgColorEmpleados).toBe('#FFFFFF')
expect(store.desktopNavbarPersistent).toBe(false)
expect(localStorageMock.getItem).toHaveBeenCalledWith(APPEARANCE_STORAGE_KEY)
})
@@ -65,7 +71,18 @@ describe('useUi Store', () => {
accentColorTareas: '#00FF00',
accentColorPlanillas: '#FFFF00',
accentColorAsistencias: '#FF00FF',
// other settings...
accentColorConfiguracion: '#112233',
tableBgColorEmpleados: '#EEEEEE',
tableBgColorTareas: '#DDDDDD',
tableBgColorPlanillas: '#CCCCCC',
tableBgColorAsistencias: '#BBBBBB',
tableBgColorConfiguracion: '#AAAAAA',
desktopNavbarPersistent: true,
defaultViewEmpleados: 'card',
defaultViewTareas: 'card',
defaultViewPlanillas: 'card',
defaultViewAsistencias: 'card',
defaultViewConfiguracion: 'card',
}
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(storedSettings))
@@ -80,9 +97,21 @@ describe('useUi Store', () => {
expect(store.accentColorTareas).toBe('#00FF00')
expect(store.accentColorPlanillas).toBe('#FFFF00')
expect(store.accentColorAsistencias).toBe('#FF00FF')
expect(store.accentColorConfiguracion).toBe('#112233')
expect(store.tableBgColorEmpleados).toBe('#EEEEEE')
expect(store.tableBgColorTareas).toBe('#DDDDDD')
expect(store.tableBgColorPlanillas).toBe('#CCCCCC')
expect(store.tableBgColorAsistencias).toBe('#BBBBBB')
expect(store.tableBgColorConfiguracion).toBe('#AAAAAA')
expect(store.desktopNavbarPersistent).toBe(true)
expect(store.defaultViewEmpleados).toBe('card')
expect(store.defaultViewTareas).toBe('card')
expect(store.defaultViewPlanillas).toBe('card')
expect(store.defaultViewAsistencias).toBe('card')
expect(store.defaultViewConfiguracion).toBe('card')
})
it('loads older settings from localStorage and uses defaults for new accent colors if not present', () => {
it('loads older settings from localStorage and uses defaults for new settings if not present', () => {
const olderStoredSettings = {
primaryColor: '#ABCDEF',
theme: 'dark',
@@ -96,10 +125,20 @@ describe('useUi Store', () => {
expect(store.theme).toBe('dark')
expect(store.fontSize).toBe(18)
// New accent colors should fall back to defaults
expect(store.accentColorEmpleados).toBe('#2196F3')
expect(store.accentColorTareas).toBe('#4CAF50')
expect(store.accentColorPlanillas).toBe('#FF9800')
expect(store.accentColorAsistencias).toBe('#E91E63')
expect(store.accentColorEmpleados).toBe('#2196F3') // Default
expect(store.accentColorTareas).toBe('#4CAF50') // Default
expect(store.accentColorPlanillas).toBe('#FF9800') // Default
expect(store.accentColorAsistencias).toBe('#E91E63') // Default
expect(store.accentColorConfiguracion).toBe('#607D8B') // Default
// New default view keys should fall back to 'table'
expect(store.defaultViewEmpleados).toBe('table')
expect(store.defaultViewTareas).toBe('table')
expect(store.defaultViewPlanillas).toBe('table')
expect(store.defaultViewAsistencias).toBe('table')
expect(store.defaultViewConfiguracion).toBe('table')
// Other new keys
expect(store.tableBgColorEmpleados).toBe('#FFFFFF') // Default
expect(store.desktopNavbarPersistent).toBe(false) // Default
})
it('falls back to default settings if localStorage data is invalid JSON', () => {
@@ -129,11 +168,14 @@ describe('useUi Store', () => {
const appearanceSettingKeysInTest = [
'primaryColor', 'secondaryColor', 'warningColor', 'fontFamily',
'fontSize', 'animationsEnabled', 'backgroundColor', 'theme',
'accentColorEmpleados', 'accentColorTareas', 'accentColorPlanillas', 'accentColorAsistencias',
const appearanceSettingKeys = [
'primaryColor', 'secondaryColor', 'warningColor', 'fontFamily',
'fontSize', 'animationsEnabled', 'backgroundColor', 'theme',
]
'accentColorEmpleados', 'accentColorTareas', 'accentColorPlanillas',
'accentColorAsistencias', 'accentColorConfiguracion',
'tableBgColorEmpleados', 'tableBgColorTareas', 'tableBgColorPlanillas',
'tableBgColorAsistencias', 'tableBgColorConfiguracion',
'desktopNavbarPersistent',
'defaultViewEmpleados', 'defaultViewTareas', 'defaultViewPlanillas',
'defaultViewAsistencias', 'defaultViewConfiguracion',
];
it('setPrimaryColor updates state and saves to localStorage', () => {
const store = useUi()
@@ -215,7 +257,9 @@ describe('useUi Store', () => {
expect(Object.keys(savedData).length).toBe(appearanceSettingKeysInTest.length);
expect(savedData.sidebarOpen).toBeUndefined() // Ensure non-appearance data is not saved
appearanceSettingKeysInTest.forEach(key => {
expect(savedData.hasOwnProperty(key)).toBe(true)
//This assertion needs to be robust if some keys are not initialized (e.g. undefined)
//However, our store initializes all appearance keys
expect(savedData.hasOwnProperty(key)).toBe(true);
})
})
@@ -259,11 +303,100 @@ describe('useUi Store', () => {
expect.stringContaining('"accentColorAsistencias":"#FF7788"')
)
})
expect(Object.keys(savedData).length).toBe(appearanceSettingKeys.length);
expect(savedData.sidebarOpen).toBeUndefined() // Ensure non-appearance data is not saved
appearanceSettingKeys.forEach(key => {
expect(savedData.hasOwnProperty(key)).toBe(true)
})
it('setAccentColorConfiguracion updates state and saves to localStorage', () => {
const store = useUi()
store.setAccentColorConfiguracion('#99AABB')
expect(store.accentColorConfiguracion).toBe('#99AABB')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"accentColorConfiguracion":"#99AABB"')
)
})
// Tests for table background color actions
it('setTableBgColorEmpleados updates state and saves to localStorage', () => {
const store = useUi()
store.setTableBgColorEmpleados('#EEECCC')
expect(store.tableBgColorEmpleados).toBe('#EEECCC')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"tableBgColorEmpleados":"#EEECCC"')
)
})
// Similar tests for Tareas, Planillas, Asistencias, Configuracion table bg colors...
it('setDesktopNavbarPersistent updates state and saves to localStorage', () => {
const store = useUi()
store.setDesktopNavbarPersistent(true)
expect(store.desktopNavbarPersistent).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"desktopNavbarPersistent":true')
)
store.setDesktopNavbarPersistent(false)
expect(store.desktopNavbarPersistent).toBe(false)
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"desktopNavbarPersistent":false')
)
})
// Tests for new default view actions
it('setDefaultViewEmpleados updates state and saves to localStorage', () => {
const store = useUi()
store.setDefaultViewEmpleados('card')
expect(store.defaultViewEmpleados).toBe('card')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"defaultViewEmpleados":"card"')
)
store.setDefaultViewEmpleados('table')
expect(store.defaultViewEmpleados).toBe('table')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"defaultViewEmpleados":"table"')
)
})
it('setDefaultViewTareas updates state and saves to localStorage', () => {
const store = useUi()
store.setDefaultViewTareas('card')
expect(store.defaultViewTareas).toBe('card')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"defaultViewTareas":"card"')
)
})
it('setDefaultViewPlanillas updates state and saves to localStorage', () => {
const store = useUi()
store.setDefaultViewPlanillas('card')
expect(store.defaultViewPlanillas).toBe('card')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"defaultViewPlanillas":"card"')
)
})
it('setDefaultViewAsistencias updates state and saves to localStorage', () => {
const store = useUi()
store.setDefaultViewAsistencias('card')
expect(store.defaultViewAsistencias).toBe('card')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"defaultViewAsistencias":"card"')
)
})
it('setDefaultViewConfiguracion updates state and saves to localStorage', () => {
const store = useUi()
store.setDefaultViewConfiguracion('card')
expect(store.defaultViewConfiguracion).toBe('card')
expect(localStorageMock.setItem).toHaveBeenCalledWith(
APPEARANCE_STORAGE_KEY,
expect.stringContaining('"defaultViewConfiguracion":"card"')
)
})
})
})

View File

@@ -23,6 +23,13 @@ const appearanceSettingKeys = [
'tableBgColorPlanillas',
'tableBgColorAsistencias',
'tableBgColorConfiguracion',
'desktopNavbarPersistent',
// Default module views
'defaultViewEmpleados',
'defaultViewTareas',
'defaultViewPlanillas',
'defaultViewAsistencias',
'defaultViewConfiguracion',
]
const loadSettingsFromLocalStorage = () => {
@@ -90,6 +97,13 @@ export const useUi = defineStore('ui', {
tableBgColorPlanillas: '#FFFFFF',
tableBgColorAsistencias: '#FFFFFF',
tableBgColorConfiguracion: '#FFFFFF',
desktopNavbarPersistent: false,
// Default module views
'defaultViewEmpleados': 'table',
'defaultViewTareas': 'table',
'defaultViewPlanillas': 'table',
'defaultViewAsistencias': 'table',
'defaultViewConfiguracion': 'table',
}
const loadedSettings = loadSettingsFromLocalStorage()
@@ -197,6 +211,32 @@ export const useUi = defineStore('ui', {
setTableBgColorConfiguracion(color) {
this.tableBgColorConfiguracion = color
_saveAppearanceState(this)
},
setDesktopNavbarPersistent(enabled) {
this.desktopNavbarPersistent = !!enabled // Ensure boolean
_saveAppearanceState(this)
},
// Actions for default module views
setDefaultViewEmpleados(view) {
this.defaultViewEmpleados = view
_saveAppearanceState(this)
},
setDefaultViewTareas(view) {
this.defaultViewTareas = view
_saveAppearanceState(this)
},
setDefaultViewPlanillas(view) {
this.defaultViewPlanillas = view
_saveAppearanceState(this)
},
setDefaultViewAsistencias(view) {
this.defaultViewAsistencias = view
_saveAppearanceState(this)
},
setDefaultViewConfiguracion(view) {
this.defaultViewConfiguracion = view
_saveAppearanceState(this)
}
},
})

View File

@@ -4,30 +4,51 @@
@tailwind utilities;
:root {
--accent-color-asistencias: #4CAF50; /* Example Green */
--accent-color-empleados: #2196F3; /* Example Blue */
--accent-color-planillas: #FF9800; /* Example Orange */
--accent-color-tareas: #9C27B0; /* Example Purple */
--warning-color: #dc3545; /* Example Red for delete buttons */
--background-color: #ffffff;
--text-color: #333333;
/* Colores base */
--primary-color: #1976D2;
--secondary-color: #424242;
--warning-color: #dc3545; /* rojo para acciones peligrosas */
--background-color:#FFFFFF;
/* Tipografía */
--font-family: 'Roboto', sans-serif;
--font-size: 16px;
/* Texto y bordes */
--text-color: #333333;
--muted-text-color: #555555;
--border-color: #e0e0e0;
--card-shadow: 0 2px 5px rgba(0,0,0,0.05);
--card-hover-shadow: 0 4px 10px rgba(0,0,0,0.1);
/* Retaining existing theme variables that don't conflict */
--primary-color: #1976D2; /* Kept from original */
--secondary-color: #424242; /* Kept from original */
--font-family: 'Roboto', sans-serif; /* Kept from original */
--font-size: 16px; /* Kept from original */
--border-color: #e0e0e0;
/* Sombras */
--card-shadow: 0 2px 5px rgba(0,0,0,0.05);
--card-hover-shadow: 0 4px 10px rgba(0,0,0,0.10);
/* Colores de módulo */
--accent-color-asistencias: #4CAF50;
--accent-color-empleados: #2196F3;
--accent-color-planillas: #FF9800;
--accent-color-tareas: #9C27B0;
/* Fondos de tabla (light) */
--table-container-bg-color-asistencias: #fdebee;
--table-container-bg-color-empleados: #e3f2fd;
--table-container-bg-color-planillas: #fff8e1;
--table-container-bg-color-tareas: #e6f4ea;
}
html.theme-dark {
--primary-color: #2196F3; /* Example dark theme primary */
--secondary-color: #757575; /* Example dark theme secondary */
--warning-color: #FFA000; /* Example dark theme warning */
--background-color: #303030; /* Dark theme background */
--text-color: #FFFFFF; /* Text color for dark theme */
/* NEW: Module-specific table container background colors (Dark Theme) */
--table-container-bg-color-asistencias: #4a1f28;
--table-container-bg-color-empleados: #193a50;
--table-container-bg-color-planillas: #503f1b;
--table-container-bg-color-tareas: #1e4028;
}
/* Apply background and text color to the body for theme changes */

View File

@@ -0,0 +1,96 @@
// ui/src/utils/formatters.js
export const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
// Using UTC methods for consistency if dates are stored in UTC
// For simple date, local might be fine, but UTC is safer for consistency across server/client
const day = String(date.getUTCDate()).padStart(2, '0');
const month = String(date.getUTCMonth() + 1).padStart(2, '0'); // Months are 0-indexed
const year = date.getUTCFullYear();
return `${day}/${month}/${year}`;
};
export const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return 'N/A';
const date = new Date(dateTimeString);
const day = String(date.getUTCDate()).padStart(2, '0');
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const year = date.getUTCFullYear();
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
};
export const formatCurrency = (value, currency = 'PYG') => {
if (value == null || isNaN(Number(value))) return 'N/A';
return Number(value).toLocaleString('es-PY', { // Default to Paraguayan Guarani
style: 'currency',
currency: currency,
minimumFractionDigits: 0, // Assuming whole numbers for PYG primarily
maximumFractionDigits: 0, // Adjust if decimals are common for the currency
});
};
export const truncateText = (text, maxLength = 50) => {
if (!text) return 'N/A';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
// Generic status class generator
// It can be expanded with more specific module-based color themes if needed later
const defaultStatusStyles = {
base: 'px-2.5 py-0.5 rounded-full text-xs font-semibold',
yellow: 'bg-yellow-100 text-yellow-800',
green: 'bg-green-100 text-green-800',
red: 'bg-red-100 text-red-800',
blue: 'bg-blue-100 text-blue-800',
gray: 'bg-gray-100 text-gray-800',
};
// Status mappings for different modules/contexts can be centralized here
// For now, using a general mapping based on common terms observed
const commonStatusMap = {
// Asistencias
'pendiente': defaultStatusStyles.yellow,
'presente': defaultStatusStyles.green,
'confirmada': defaultStatusStyles.green,
'ausente': defaultStatusStyles.red,
'justificada': defaultStatusStyles.blue,
'cancelada': defaultStatusStyles.gray, // Could be red too, gray for less severe
'anulada': defaultStatusStyles.gray, // Same as cancelada
// Planillas
'pagado': defaultStatusStyles.green,
// 'pendiente' is already mapped
// 'anulado' is already mapped
// Tareas
// 'pendiente' is already mapped
'realizada': defaultStatusStyles.green,
'completada': defaultStatusStyles.green,
'hecho': defaultStatusStyles.green,
'en-progreso': defaultStatusStyles.blue,
// 'anulada' is already mapped
// 'cancelada' is already mapped
};
export const getStatusClass = (status) => {
const baseClasses = defaultStatusStyles.base;
if (!status) return `${baseClasses} ${defaultStatusStyles.gray}`;
const lowerStatus = String(status).toLowerCase().replace(/\s+/g, '-');
const style = commonStatusMap[lowerStatus] || defaultStatusStyles.gray;
return `${baseClasses} ${style}`;
};
// Example of how it could be more module specific if needed:
/*
export const getAsistenciaStatusClass = (status) => {
const baseClasses = defaultStatusStyles.base;
// ... Asistencia specific logic ...
return `${baseClasses} ${specificStyle}`;
}
*/

View File

@@ -21,6 +21,11 @@
<input type="checkbox" id="animationsEnabled" v-model="ui.animationsEnabled" @change="ui.setAnimationsEnabled($event.target.checked)"
class="custom-checkbox relative w-10 h-5 appearance-none bg-gray-300 dark:bg-gray-600 rounded-full cursor-pointer transition-colors duration-300 ease-in-out checked:bg-[var(--primary-color)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--primary-color)] focus:ring-offset-[var(--background-color)]">
</div>
<div class="setting-item flex items-center justify-between mt-4 md:mt-0 md:pt-6">
<label for="desktopNavbarPersistent" class="text-sm font-medium">Persistent Desktop Navbar</label>
<input type="checkbox" id="desktopNavbarPersistent" v-model="ui.desktopNavbarPersistent" @change="ui.setDesktopNavbarPersistent($event.target.checked)"
class="custom-checkbox relative w-10 h-5 appearance-none bg-gray-300 dark:bg-gray-600 rounded-full cursor-pointer transition-colors duration-300 ease-in-out checked:bg-[var(--primary-color)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--primary-color)] focus:ring-offset-[var(--background-color)]">
</div>
</div>
</section>
@@ -73,7 +78,7 @@
<!-- Per-Module Color Settings -->
<section class="mb-10 module-settings-group">
<h3 class="text-xl font-semibold mb-4 text-[var(--primary-color)] border-b border-[var(--secondary-color)] pb-2">Empleados Module</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div class="setting-item">
<label for="accentColorEmpleados" class="block text-sm font-medium mb-1">Accent Color</label>
<input type="color" id="accentColorEmpleados" v-model="ui.accentColorEmpleados" @input="ui.setAccentColorEmpleados($event.target.value)"
@@ -84,12 +89,20 @@
<input type="color" id="tableBgColorEmpleados" v-model="ui.tableBgColorEmpleados" @input="ui.setTableBgColorEmpleados($event.target.value)"
class="w-full h-12 p-1 border rounded-lg cursor-pointer shadow-sm hover:opacity-80 transition-opacity border-[var(--secondary-color)] focus:border-[var(--primary-color)] focus:ring-1 focus:ring-[var(--primary-color)]">
</div>
<div class="setting-item">
<label for="defaultViewEmpleados" class="block text-sm font-medium mb-1">Default View</label>
<select id="defaultViewEmpleados" v-model="ui.defaultViewEmpleados" @change="ui.setDefaultViewEmpleados($event.target.value)"
class="w-full p-3 border rounded-lg shadow-sm focus:ring-[var(--primary-color)] focus:border-[var(--primary-color)] transition-all duration-150 ease-in-out bg-white/10 dark:bg-black/10 border-[var(--secondary-color)] hover:border-[var(--primary-color)]">
<option value="table">Table</option>
<option value="card">Card</option>
</select>
</div>
</div>
</section>
<section class="mb-10 module-settings-group">
<h3 class="text-xl font-semibold mb-4 text-[var(--primary-color)] border-b border-[var(--secondary-color)] pb-2">Tareas Module</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div class="setting-item">
<label for="accentColorTareas" class="block text-sm font-medium mb-1">Accent Color</label>
<input type="color" id="accentColorTareas" v-model="ui.accentColorTareas" @input="ui.setAccentColorTareas($event.target.value)"
@@ -100,12 +113,20 @@
<input type="color" id="tableBgColorTareas" v-model="ui.tableBgColorTareas" @input="ui.setTableBgColorTareas($event.target.value)"
class="w-full h-12 p-1 border rounded-lg cursor-pointer shadow-sm hover:opacity-80 transition-opacity border-[var(--secondary-color)] focus:border-[var(--primary-color)] focus:ring-1 focus:ring-[var(--primary-color)]">
</div>
<div class="setting-item">
<label for="defaultViewTareas" class="block text-sm font-medium mb-1">Default View</label>
<select id="defaultViewTareas" v-model="ui.defaultViewTareas" @change="ui.setDefaultViewTareas($event.target.value)"
class="w-full p-3 border rounded-lg shadow-sm focus:ring-[var(--primary-color)] focus:border-[var(--primary-color)] transition-all duration-150 ease-in-out bg-white/10 dark:bg-black/10 border-[var(--secondary-color)] hover:border-[var(--primary-color)]">
<option value="table">Table</option>
<option value="card">Card</option>
</select>
</div>
</div>
</section>
<section class="mb-10 module-settings-group">
<h3 class="text-xl font-semibold mb-4 text-[var(--primary-color)] border-b border-[var(--secondary-color)] pb-2">Planillas Module</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div class="setting-item">
<label for="accentColorPlanillas" class="block text-sm font-medium mb-1">Accent Color</label>
<input type="color" id="accentColorPlanillas" v-model="ui.accentColorPlanillas" @input="ui.setAccentColorPlanillas($event.target.value)"
@@ -116,12 +137,20 @@
<input type="color" id="tableBgColorPlanillas" v-model="ui.tableBgColorPlanillas" @input="ui.setTableBgColorPlanillas($event.target.value)"
class="w-full h-12 p-1 border rounded-lg cursor-pointer shadow-sm hover:opacity-80 transition-opacity border-[var(--secondary-color)] focus:border-[var(--primary-color)] focus:ring-1 focus:ring-[var(--primary-color)]">
</div>
<div class="setting-item">
<label for="defaultViewPlanillas" class="block text-sm font-medium mb-1">Default View</label>
<select id="defaultViewPlanillas" v-model="ui.defaultViewPlanillas" @change="ui.setDefaultViewPlanillas($event.target.value)"
class="w-full p-3 border rounded-lg shadow-sm focus:ring-[var(--primary-color)] focus:border-[var(--primary-color)] transition-all duration-150 ease-in-out bg-white/10 dark:bg-black/10 border-[var(--secondary-color)] hover:border-[var(--primary-color)]">
<option value="table">Table</option>
<option value="card">Card</option>
</select>
</div>
</div>
</section>
<section class="mb-10 module-settings-group">
<h3 class="text-xl font-semibold mb-4 text-[var(--primary-color)] border-b border-[var(--secondary-color)] pb-2">Asistencias Module</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div class="setting-item">
<label for="accentColorAsistencias" class="block text-sm font-medium mb-1">Accent Color</label>
<input type="color" id="accentColorAsistencias" v-model="ui.accentColorAsistencias" @input="ui.setAccentColorAsistencias($event.target.value)"
@@ -132,12 +161,20 @@
<input type="color" id="tableBgColorAsistencias" v-model="ui.tableBgColorAsistencias" @input="ui.setTableBgColorAsistencias($event.target.value)"
class="w-full h-12 p-1 border rounded-lg cursor-pointer shadow-sm hover:opacity-80 transition-opacity border-[var(--secondary-color)] focus:border-[var(--primary-color)] focus:ring-1 focus:ring-[var(--primary-color)]">
</div>
<div class="setting-item">
<label for="defaultViewAsistencias" class="block text-sm font-medium mb-1">Default View</label>
<select id="defaultViewAsistencias" v-model="ui.defaultViewAsistencias" @change="ui.setDefaultViewAsistencias($event.target.value)"
class="w-full p-3 border rounded-lg shadow-sm focus:ring-[var(--primary-color)] focus:border-[var(--primary-color)] transition-all duration-150 ease-in-out bg-white/10 dark:bg-black/10 border-[var(--secondary-color)] hover:border-[var(--primary-color)]">
<option value="table">Table</option>
<option value="card">Card</option>
</select>
</div>
</div>
</section>
<section class="mb-10 module-settings-group">
<h3 class="text-xl font-semibold mb-4 text-[var(--primary-color)] border-b border-[var(--secondary-color)] pb-2">Configuración Module</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div class="setting-item">
<label for="accentColorConfiguracion" class="block text-sm font-medium mb-1">Accent Color</label>
<input type="color" id="accentColorConfiguracion" v-model="ui.accentColorConfiguracion" @input="ui.setAccentColorConfiguracion($event.target.value)"
@@ -148,6 +185,14 @@
<input type="color" id="tableBgColorConfiguracion" v-model="ui.tableBgColorConfiguracion" @input="ui.setTableBgColorConfiguracion($event.target.value)"
class="w-full h-12 p-1 border rounded-lg cursor-pointer shadow-sm hover:opacity-80 transition-opacity border-[var(--secondary-color)] focus:border-[var(--primary-color)] focus:ring-1 focus:ring-[var(--primary-color)]">
</div>
<div class="setting-item">
<label for="defaultViewConfiguracion" class="block text-sm font-medium mb-1">Default View</label>
<select id="defaultViewConfiguracion" v-model="ui.defaultViewConfiguracion" @change="ui.setDefaultViewConfiguracion($event.target.value)"
class="w-full p-3 border rounded-lg shadow-sm focus:ring-[var(--primary-color)] focus:border-[var(--primary-color)] transition-all duration-150 ease-in-out bg-white/10 dark:bg-black/10 border-[var(--secondary-color)] hover:border-[var(--primary-color)]">
<option value="table">Table</option>
<option value="card">Card</option>
</select>
</div>
</div>
</section>
</div>

View File

@@ -4,89 +4,68 @@ import { createPinia, setActivePinia } from 'pinia'
import { useUi } from '../../stores/useUi' // Adjust path
import SettingsView from '../SettingsView.vue' // Adjust path
// Helper to create a fresh store for each test or group
const getFreshStore = () => {
setActivePinia(createPinia())
return useUi()
}
// No global 'store' variable here, it will be test-specific or wrapper-specific
// Helper to manage store and wrapper creation for tests
const setupTestEnvironment = (initialStoreState = {}) => {
setActivePinia(createPinia()); // Ensures a fresh Pinia instance
// Mock localStorage for the store if not already globally mocked by Vitest setup
// Ensure this mock is consistent with how useUi.js uses localStorage
const localStorageMock = (() => {
let lsStore = { ...initialStoreState }; // Initialize with initialStoreState for loading
return {
getItem: vi.fn((key) => {
const value = lsStore[key];
// Pinia/useUi expects JSON string or null
return typeof value === 'object' ? JSON.stringify(value) : value;
}),
setItem: vi.fn((key, value) => {
// Store as string, as localStorage does
lsStore[key] = value.toString();
// Make actual saved string available for inspection if needed
// This helps verify what _saveAppearanceState would do
if (key === 'appearanceSettings') {
lsStore['_raw_appearanceSettings'] = value.toString();
}
}),
clear: vi.fn(() => { lsStore = {}; }),
removeItem: vi.fn((key) => { delete lsStore[key]; }),
// Helper to get the raw string for assertion
getRawItem: (key) => lsStore[key]
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true });
// If initialStoreState is provided, simulate it being in localStorage
// The useUi store reads from localStorage upon creation.
if (Object.keys(initialStoreState).length > 0) {
localStorage.setItem('appearanceSettings', JSON.stringify(initialStoreState));
} else {
localStorage.removeItem('appearanceSettings'); // Ensure no settings if none provided
}
const store = useUi(); // This will now load from the mocked localStorage
const wrapper = mount(SettingsView, {
global: {
// Pinia store is automatically available due to setActivePinia
},
});
return { wrapper, store, localStorageMock };
};
describe('SettingsView.vue', () => {
// No global 'store' variable here, it will be test-specific or wrapper-specific
beforeEach(() => {
// Ensure a fresh Pinia instance is active for each test.
setActivePinia(createPinia())
// Clear localStorage mock before each test
// Note: The mock itself is defined globally in this file or via setupFiles in Vitest config.
// Re-stubbing it or ensuring its state is clean.
const localStorageMock = window.localStorage; // Assuming it's already stubbed globally by Vitest setup or top of file
localStorageMock.clear();
if (localStorageMock.setItem.mockClear) { // vi.fn() specific
localStorageMock.setItem.mockClear();
localStorageMock.getItem.mockClear();
}
})
// createWrapper now also handles store creation and returns the store for spying if needed
const createWrapperAndStore = (initialStoreState = {}) => {
if (Object.keys(initialStoreState).length > 0) {
localStorage.setItem('appearanceSettings', JSON.stringify(initialStoreState));
}
const currentStore = useUi(); // Store is created AFTER localStorage is set for this test
const wrapper = mount(SettingsView, {
global: {
// Pinia is active via setActivePinia, component should pick it up
},
});
return { wrapper, store: currentStore }; // Return store for spying
}
// Vitest's vi.clearAllMocks() might be useful here if mocks persist unexpectedly
// This setupTestEnvironment function already handles fresh store and localStorage mock per call.
// If window.localStorage was stubbed globally (e.g. in vitest.setup.js), ensure it's reset.
// For this example, setupTestEnvironment manages its own localStorage mock.
});
it('renders all input elements with initial values from store', () => {
const { wrapper } = createWrapperAndStore({ // Store is created inside here after LS mock
let store
beforeEach(() => {
// Create a new Pinia instance and activate it for each test
// This also resets the store state for each test
store = getFreshStore()
// Mock localStorage for the store
const localStorageMock = (() => {
let lsStore = {}
return {
getItem: vi.fn((key) => lsStore[key] || null),
setItem: vi.fn((key, value) => { lsStore[key] = value.toString() }),
clear: vi.fn(() => { lsStore = {} }),
removeItem: vi.fn((key) => { delete lsStore[key] }),
}
})()
Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true })
localStorageMock.clear()
})
const createWrapper = (initialStoreState = {}) => {
// Apply initial state to the store if provided
// This is a bit of a workaround as direct state mutation isn't ideal,
// but for testing initial binding it can be simpler than calling actions.
// Alternatively, set up localStorage then init store.
if (Object.keys(initialStoreState).length > 0) {
localStorage.setItem('appearanceSettings', JSON.stringify(initialStoreState))
}
// Re-initialize store to pick up mocked localStorage if initialStoreState was set
store = getFreshStore()
return mount(SettingsView, {
global: {
// plugins: [store.$pinia], // Removed: setActivePinia should make it available
},
})
}
it('renders all input elements with initial values from store', () => {
const wrapper = createWrapper({
const { wrapper } = setupTestEnvironment({
primaryColor: '#111111',
secondaryColor: '#222222',
warningColor: '#333333',
@@ -96,148 +75,181 @@ describe('SettingsView.vue', () => {
animationsEnabled: false,
theme: 'dark',
// Add new accent colors for comprehensive initial state testing
primaryColor: '#111111',
secondaryColor: '#222222',
warningColor: '#333333',
backgroundColor: '#444444',
fontFamily: 'Arial',
fontSize: 18,
animationsEnabled: false,
theme: 'dark',
accentColorEmpleados: '#123456',
accentColorTareas: '#654321',
accentColorPlanillas: '#abcdef',
accentColorAsistencias: '#fedcba',
})
accentColorConfiguracion: '#aabbcc',
defaultViewEmpleados: 'card', // New default view
defaultViewTareas: 'table',
defaultViewPlanillas: 'card',
defaultViewAsistencias: 'table',
defaultViewConfiguracion: 'card',
});
// Check general appearance settings
})
expect(wrapper.find('input#primaryColor').element.value).toBe('#111111')
expect(wrapper.find('input#secondaryColor').element.value).toBe('#222222')
expect(wrapper.find('input#primaryColor').element.value).toBe('#111111');
expect(wrapper.find('input#secondaryColor').element.value).toBe('#222222');
expect(wrapper.find('input#warningColor').element.value).toBe('#333333')
expect(wrapper.find('input#backgroundColor').element.value).toBe('#444444')
expect(wrapper.find('input#fontFamily').element.value).toBe('Arial')
expect(wrapper.find('input#fontSize').element.value).toBe('18')
expect(wrapper.find('input#animationsEnabled').element.checked).toBe(false)
expect(wrapper.find('select#theme').element.value).toBe('dark')
expect(wrapper.find('input#animationsEnabled').element.checked).toBe(false);
expect(wrapper.find('select#theme').element.value).toBe('dark');
// Check new module accent color pickers
const h2Elements = wrapper.findAll('h2')
const sectionTitles = h2Elements.map(h => h.text())
expect(sectionTitles).toContain('Module Accent Colors')
expect(wrapper.find('input#accentColorEmpleados').element.value).toBe('#123456')
expect(wrapper.find('input#accentColorTareas').element.value).toBe('#654321')
expect(wrapper.find('input#accentColorPlanillas').element.value).toBe('#abcdef')
expect(wrapper.find('input#accentColorAsistencias').element.value).toBe('#fedcba')
})
// Check module accent color pickers
expect(wrapper.find('input#accentColorEmpleados').element.value).toBe('#123456');
expect(wrapper.find('input#accentColorTareas').element.value).toBe('#654321');
expect(wrapper.find('input#accentColorPlanillas').element.value).toBe('#abcdef');
expect(wrapper.find('input#accentColorAsistencias').element.value).toBe('#fedcba');
expect(wrapper.find('input#accentColorConfiguracion').element.value).toBe('#aabbcc');
// Check new default view select elements
expect(wrapper.find('select#defaultViewEmpleados').element.value).toBe('card');
expect(wrapper.find('select#defaultViewTareas').element.value).toBe('table');
expect(wrapper.find('select#defaultViewPlanillas').element.value).toBe('card');
expect(wrapper.find('select#defaultViewAsistencias').element.value).toBe('table');
expect(wrapper.find('select#defaultViewConfiguracion').element.value).toBe('card');
});
it('calls setPrimaryColor action when primary color input changes', async () => {
const { wrapper, store } = createWrapperAndStore()
const spy = vi.spyOn(store, 'setPrimaryColor')
const colorInput = wrapper.find('input#primaryColor')
colorInput.element.value = '#FF00FF'
await colorInput.trigger('input')
expect(spy).toHaveBeenCalledWith('#ff00ff')
})
const { wrapper, store } = setupTestEnvironment();
const spy = vi.spyOn(store, 'setPrimaryColor');
const colorInput = wrapper.find('input#primaryColor');
colorInput.element.value = '#FF00FF';
await colorInput.trigger('input');
expect(spy).toHaveBeenCalledWith('#ff00ff');
});
it('calls setFontFamily action when font family input changes', async () => {
const { wrapper, store } = createWrapperAndStore()
const spy = vi.spyOn(store, 'setFontFamily')
const input = wrapper.find('input#fontFamily')
await input.setValue('Helvetica')
})
it('calls setPrimaryColor action when primary color input changes', async () => {
const wrapper = createWrapper()
const spy = vi.spyOn(store, 'setPrimaryColor')
const colorInput = wrapper.find('input#primaryColor')
// Simulate color picker actually setting the value and then dispatching input
// For input type=color, setting .value and then .trigger('input') is typical
colorInput.element.value = '#FF00FF'
await colorInput.trigger('input')
expect(spy).toHaveBeenCalledWith('#ff00ff') // Changed to lowercase
})
it('calls setFontFamily action when font family input changes', async () => {
const wrapper = createWrapper()
const spy = vi.spyOn(store, 'setFontFamily')
const input = wrapper.find('input#fontFamily')
await input.setValue('Helvetica') // .setValue also triggers 'input'
expect(spy).toHaveBeenCalledWith('Helvetica')
})
const { wrapper, store } = setupTestEnvironment();
const spy = vi.spyOn(store, 'setFontFamily');
const input = wrapper.find('input#fontFamily');
await input.setValue('Helvetica');
expect(spy).toHaveBeenCalledWith('Helvetica');
});
it('calls setFontSize action when font size input changes', async () => {
const { wrapper, store } = createWrapperAndStore()
const wrapper = createWrapper()
const spy = vi.spyOn(store, 'setFontSize')
const input = wrapper.find('input#fontSize')
await input.setValue('22')
expect(spy).toHaveBeenCalledWith(22)
})
const { wrapper, store } = setupTestEnvironment();
const spy = vi.spyOn(store, 'setFontSize');
const input = wrapper.find('input#fontSize');
await input.setValue('22');
expect(spy).toHaveBeenCalledWith(22);
});
it('calls setAnimationsEnabled action when animations checkbox changes', async () => {
const { wrapper, store } = createWrapperAndStore({ animationsEnabled: true })
const spy = vi.spyOn(store, 'setAnimationsEnabled')
const checkbox = wrapper.find('input#animationsEnabled')
await checkbox.setChecked(false)
const wrapper = createWrapper({ animationsEnabled: true }) // Start with true
const spy = vi.spyOn(store, 'setAnimationsEnabled')
const checkbox = wrapper.find('input#animationsEnabled')
// For checkboxes, .setValue(false) or .setChecked(false) and then trigger 'change'
await checkbox.setChecked(false) // This should trigger the change event for v-model
expect(spy).toHaveBeenCalledWith(false)
})
const { wrapper, store } = setupTestEnvironment({ animationsEnabled: true });
const spy = vi.spyOn(store, 'setAnimationsEnabled');
const checkbox = wrapper.find('input#animationsEnabled');
await checkbox.setChecked(false);
expect(spy).toHaveBeenCalledWith(false);
});
it('calls setTheme action when theme select changes', async () => {
const { wrapper, store } = createWrapperAndStore()
const spy = vi.spyOn(store, 'setTheme')
const select = wrapper.find('select#theme')
await select.setValue('dark')
expect(spy).toHaveBeenCalledWith('dark')
})
const { wrapper, store } = setupTestEnvironment();
const spy = vi.spyOn(store, 'setTheme');
const select = wrapper.find('select#theme');
await select.setValue('dark');
expect(spy).toHaveBeenCalledWith('dark');
});
// New tests for module accent color pickers
// Tests for module accent color pickers
it('calls setAccentColorEmpleados action when empleados accent color input changes', async () => {
const { wrapper, store } = createWrapperAndStore()
const spy = vi.spyOn(store, 'setAccentColorEmpleados')
const colorInput = wrapper.find('input#accentColorEmpleados')
colorInput.element.value = '#aabbcc'
await colorInput.trigger('input')
expect(spy).toHaveBeenCalledWith('#aabbcc')
})
const { wrapper, store } = setupTestEnvironment();
const spy = vi.spyOn(store, 'setAccentColorEmpleados');
const colorInput = wrapper.find('input#accentColorEmpleados');
colorInput.element.value = '#aabbcc';
await colorInput.trigger('input');
expect(spy).toHaveBeenCalledWith('#aabbcc');
});
it('calls setAccentColorTareas action when tareas accent color input changes', async () => {
const { wrapper, store } = createWrapperAndStore()
const spy = vi.spyOn(store, 'setAccentColorTareas')
const colorInput = wrapper.find('input#accentColorTareas')
colorInput.element.value = '#ccbbaa'
await colorInput.trigger('input')
expect(spy).toHaveBeenCalledWith('#ccbbaa')
})
it('calls setAccentColorConfiguracion action when configuracion accent color input changes', async () => {
const { wrapper, store } = setupTestEnvironment();
const spy = vi.spyOn(store, 'setAccentColorConfiguracion');
const colorInput = wrapper.find('input#accentColorConfiguracion');
colorInput.element.value = '#ccddee';
await colorInput.trigger('input');
expect(spy).toHaveBeenCalledWith('#ccddee');
});
it('calls setAccentColorPlanillas action when planillas accent color input changes', async () => {
const { wrapper, store } = createWrapperAndStore()
const spy = vi.spyOn(store, 'setAccentColorPlanillas')
const colorInput = wrapper.find('input#accentColorPlanillas')
colorInput.element.value = '#a1b2c3'
await colorInput.trigger('input')
expect(spy).toHaveBeenCalledWith('#a1b2c3')
})
it('calls setAccentColorAsistencias action when asistencias accent color input changes', async () => {
const { wrapper, store } = createWrapperAndStore()
const spy = vi.spyOn(store, 'setAccentColorAsistencias')
const colorInput = wrapper.find('input#accentColorAsistencias')
colorInput.element.value = '#c3b2a1'
await colorInput.trigger('input')
expect(spy).toHaveBeenCalledWith('#c3b2a1')
})
// *** ADD NEW TESTS FOR DEFAULT VIEW SELECTS HERE ***
describe('Default View Selects', () => {
it('renders default view select for Empleados module and calls action on change', async () => {
const { wrapper, store } = setupTestEnvironment({ defaultViewEmpleados: 'table' });
const select = wrapper.find('select#defaultViewEmpleados');
expect(select.exists()).toBe(true);
expect(select.element.value).toBe('table');
const options = select.findAll('option');
expect(options.length).toBe(2);
expect(options[0].text()).toBe('Table');
expect(options[0].element.value).toBe('table');
expect(options[1].text()).toBe('Card');
expect(options[1].element.value).toBe('card');
const spy = vi.spyOn(store, 'setDefaultViewEmpleados');
await select.setValue('card');
expect(spy).toHaveBeenCalledWith('card');
expect(select.element.value).toBe('card'); // Assuming v-model updates from store mock correctly
});
it('renders default view select for Tareas module and calls action on change', async () => {
const { wrapper, store } = setupTestEnvironment({ defaultViewTareas: 'table' });
const select = wrapper.find('select#defaultViewTareas');
expect(select.exists()).toBe(true);
expect(select.element.value).toBe('table');
const spy = vi.spyOn(store, 'setDefaultViewTareas');
await select.setValue('card');
expect(spy).toHaveBeenCalledWith('card');
expect(select.element.value).toBe('card');
});
it('renders default view select for Planillas module and calls action on change', async () => {
const { wrapper, store } = setupTestEnvironment({ defaultViewPlanillas: 'table' });
const select = wrapper.find('select#defaultViewPlanillas');
expect(select.exists()).toBe(true);
expect(select.element.value).toBe('table');
const spy = vi.spyOn(store, 'setDefaultViewPlanillas');
await select.setValue('card');
expect(spy).toHaveBeenCalledWith('card');
expect(select.element.value).toBe('card');
});
it('renders default view select for Asistencias module and calls action on change', async () => {
const { wrapper, store } = setupTestEnvironment({ defaultViewAsistencias: 'table' });
const select = wrapper.find('select#defaultViewAsistencias');
expect(select.exists()).toBe(true);
expect(select.element.value).toBe('table');
const spy = vi.spyOn(store, 'setDefaultViewAsistencias');
await select.setValue('card');
expect(spy).toHaveBeenCalledWith('card');
expect(select.element.value).toBe('card');
});
it('renders default view select for Configuracion module and calls action on change', async () => {
const { wrapper, store } = setupTestEnvironment({ defaultViewConfiguracion: 'table' });
const select = wrapper.find('select#defaultViewConfiguracion');
expect(select.exists()).toBe(true);
expect(select.element.value).toBe('table');
const spy = vi.spyOn(store, 'setDefaultViewConfiguracion');
await select.setValue('card');
expect(spy).toHaveBeenCalledWith('card');
expect(select.element.value).toBe('card');
});
});
it('updates input values when store state changes programmatically', async () => {
const { wrapper, store } = createWrapperAndStore() // Store instance for this test
const { wrapper, store } = setupTestEnvironment();
// Test primaryColor
store.primaryColor = '#001122' // Directly manipulate the store used by the component
store.primaryColor = '#001122';
=======
const wrapper = createWrapper()
const spy = vi.spyOn(store, 'setTheme')
@@ -251,51 +263,43 @@ describe('SettingsView.vue', () => {
// Test primaryColor
store.primaryColor = '#001122'
await wrapper.vm.$nextTick() // Wait for Vue to react to state change
expect(wrapper.find('input#primaryColor').element.value).toBe('#001122')
await wrapper.vm.$nextTick();
expect(wrapper.find('input#primaryColor').element.value).toBe('#001122');
// Test fontFamily
store.fontFamily = 'Verdana'
await wrapper.vm.$nextTick()
expect(wrapper.find('input#fontFamily').element.value).toBe('Verdana')
store.fontFamily = 'Verdana';
await wrapper.vm.$nextTick();
expect(wrapper.find('input#fontFamily').element.value).toBe('Verdana');
// Test fontSize
store.fontSize = 12
await wrapper.vm.$nextTick()
expect(wrapper.find('input#fontSize').element.value).toBe('12')
store.fontSize = 12;
await wrapper.vm.$nextTick();
expect(wrapper.find('input#fontSize').element.value).toBe('12');
// Test animationsEnabled
store.animationsEnabled = false
await wrapper.vm.$nextTick()
expect(wrapper.find('input#animationsEnabled').element.checked).toBe(false)
store.animationsEnabled = false;
await wrapper.vm.$nextTick();
expect(wrapper.find('input#animationsEnabled').element.checked).toBe(false);
// Test theme
store.theme = 'dark'
await wrapper.vm.$nextTick()
expect(wrapper.find('select#theme').element.value).toBe('dark')
store.theme = 'dark';
await wrapper.vm.$nextTick();
expect(wrapper.find('select#theme').element.value).toBe('dark');
// Test one of the new accent colors
store.accentColorEmpleados = '#998877'
await wrapper.vm.$nextTick()
expect(wrapper.find('input#accentColorEmpleados').element.value).toBe('#998877')
})
store.accentColorEmpleados = '#998877';
await wrapper.vm.$nextTick();
expect(wrapper.find('input#accentColorEmpleados').element.value).toBe('#998877');
// Test one of the new default view selects
store.defaultViewEmpleados = 'card';
await wrapper.vm.$nextTick();
expect(wrapper.find('select#defaultViewEmpleados').element.value).toBe('card');
});
// Test for the initial fade-in animation - checking class
it('applies opacity transition class after mount', async () => {
// Mock setTimeout to control its execution
vi.useFakeTimers()
vi.useFakeTimers();
const { wrapper } = setupTestEnvironment();
expect(wrapper.find('.settings-view').classes()).not.toContain('opacity-100');
const { wrapper } = createWrapperAndStore() // Use the new function name
const wrapper = createWrapper()
expect(wrapper.find('.settings-view').classes()).not.toContain('opacity-100')
// Advance timers by the amount used in setTimeout in SettingsView.vue (50ms)
vi.advanceTimersByTime(100) // Advance a bit more to be sure
await wrapper.vm.$nextTick() // Allow Vue to re-render
expect(wrapper.find('.settings-view').classes()).toContain('opacity-100')
vi.useRealTimers() // Restore real timers
})
})
vi.advanceTimersByTime(100);
await wrapper.vm.$nextTick();
expect(wrapper.find('.settings-view').classes()).toContain('opacity-100');
vi.useRealTimers();
});
});

View File

@@ -7,13 +7,23 @@
</button>
</header>
<div class="mb-6 flex justify-end items-center space-x-3">
<span class="text-sm font-medium text-gray-700">Cambiar Vista:</span>
<button @click="currentView = 'card'" :class="btnClass('card')">
Tarjetas
<!-- View Toggle Buttons -->
<div class="mb-4 flex justify-end space-x-2">
<button
@click="currentView = 'table'"
:class="btnViewClass('table')"
aria-label="Table View"
title="Table View"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
</button>
<button @click="currentView = 'table'" :class="btnClass('table')">
Tabla
<button
@click="currentView = 'card'"
:class="btnViewClass('card')"
aria-label="Card View"
title="Card View"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
</button>
</div>
@@ -60,18 +70,21 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useAsistenciasStore } from '../../stores/useAsistencias';
import { useUi } from '../../stores/useUi'; // Import useUi
import { useRouter } from 'vue-router';
import TablaAsistencias from '../../components/asistencias/tablaAsistencias.vue';
import CardAsistencia from '../../components/asistencias/cardAsistencia.vue';
const asistenciasStore = useAsistenciasStore();
const ui = useUi(); // Access the ui store
const router = useRouter();
const isLoading = ref(true);
const errorLoading = ref(false);
const errorMessage = ref('');
const currentView = ref('table'); // Default to table view
// Initialize currentView from the store's default setting for asistencias
const currentView = ref(ui.defaultViewAsistencias);
const asistenciasList = computed(() => asistenciasStore.asistencias);
@@ -100,12 +113,12 @@ const handleEditAsistencia = (asistenciaId) => {
router.push({ name: 'asistencias-edit', params: { id: asistenciaId } });
};
const btnClass = (view) => {
const baseClasses = 'px-4 py-2 rounded-md text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2';
if (currentView.value === view) {
return `${baseClasses} text-white shadow-sm view-toggle-active-asistencias`;
const btnViewClass = (viewType) => {
const base = 'p-2 rounded-md transition-colors duration-150 ease-in-out';
if (currentView.value === viewType) {
return `${base} bg-[var(--accent-color-asistencias)] text-white shadow-lg`;
}
return `${baseClasses} bg-gray-200 text-gray-700 hover:bg-gray-300 focus:ring-gray-400`;
return `${base} bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600`;
};
</script>

View File

@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useUi } from '@/stores/useUi'
import { useAsistenciasStore } from '@/stores/useAsistencias'
import AsistenciasIndex from '../AsistenciasIndex.vue'
import TablaAsistencias from '@/components/asistencias/tablaAsistencias.vue'
import CardAsistencia from '@/components/asistencias/cardAsistencia.vue'
// Mock child components
vi.mock('@/components/asistencias/tablaAsistencias.vue', () => ({
default: {
name: 'TablaAsistencias',
props: ['asistencias', 'isLoading'], // Match actual props if different
emits: ['edit'],
template: '<div data-testid="tabla-asistencias"></div>',
},
}))
vi.mock('@/components/asistencias/cardAsistencia.vue', () => ({
default: {
name: 'CardAsistencia',
props: ['asistencia'], // Match actual props
emits: ['edit'],
template: '<div data-testid="card-asistencia"></div>',
},
}))
// Mock stores
const mockSetDefaultViewAsistencias = vi.fn();
const mockFetchAsistencias = vi.fn();
vi.mock('@/stores/useUi', () => ({
useUi: vi.fn(() => ({
defaultViewAsistencias: 'table', // Default mock value
setDefaultViewAsistencias: mockSetDefaultViewAsistencias,
})),
}))
vi.mock('@/stores/useAsistencias', () => ({
useAsistenciasStore: vi.fn(() => ({
asistencias: [],
fetchAsistencias: mockFetchAsistencias,
})),
}))
describe('AsistenciasIndex.vue', () => {
let uiStoreMock
let asistenciasStoreMock
beforeEach(() => {
setActivePinia(createPinia())
mockFetchAsistencias.mockClear().mockResolvedValue([])
mockSetDefaultViewAsistencias.mockClear()
uiStoreMock = useUi()
asistenciasStoreMock = useAsistenciasStore()
})
const mountComponent = () => {
return mount(AsistenciasIndex, {
global: {
// Stubs can be defined here too if not using vi.mock for everything
},
})
}
it('fetches asistencias on mount', async () => {
mountComponent()
// It seems the component has its own isLoading ref, wait for it to resolve
await mockFetchAsistencias(); // Ensure the promise resolves
expect(mockFetchAsistencias).toHaveBeenCalledTimes(1)
})
describe('View Rendering based on useUi store', () => {
it('renders TablaAsistencias when defaultViewAsistencias is "table"', async () => {
uiStoreMock.defaultViewAsistencias = 'table'
asistenciasStoreMock.asistencias = [{ id: 1, empleado: 'Test Employee', fecha: '2023-01-01' }]
const wrapper = mountComponent()
// Wait for internal loading state and then data processing
await mockFetchAsistencias();
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'TablaAsistencias' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'TablaAsistencias' }).props('asistencias')).toEqual(asistenciasStoreMock.asistencias)
expect(wrapper.findComponent({ name: 'CardAsistencia' }).exists()).toBe(false)
})
it('renders CardAsistencia when defaultViewAsistencias is "card"', async () => {
uiStoreMock.defaultViewAsistencias = 'card'
asistenciasStoreMock.asistencias = [{ id: 1, empleado: 'E1' }, { id: 2, empleado: 'E2' }]
const wrapper = mountComponent()
await mockFetchAsistencias();
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
const cardWrappers = wrapper.findAllComponents({ name: 'CardAsistencia' })
expect(cardWrappers.length).toBe(asistenciasStoreMock.asistencias.length)
expect(cardWrappers[0].props('asistencia')).toEqual(asistenciasStoreMock.asistencias[0])
expect(wrapper.findComponent({ name: 'TablaAsistencias' }).exists()).toBe(false)
})
it('renders no data message for table view when no asistencias exist', async () => {
uiStoreMock.defaultViewAsistencias = 'table';
asistenciasStoreMock.asistencias = [];
const wrapper = mountComponent();
await mockFetchAsistencias();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'TablaAsistencias' }).exists()).toBe(true);
// The component might show "Cargando asistencias..." initially, then the no data message.
// We need to ensure loading is complete.
expect(wrapper.text()).toContain('No hay asistencias para mostrar');
});
it('renders no data message for card view when no asistencias exist', async () => {
uiStoreMock.defaultViewAsistencias = 'card';
asistenciasStoreMock.asistencias = [];
const wrapper = mountComponent();
await mockFetchAsistencias();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.findAllComponents({ name: 'CardAsistencia' }).length).toBe(0);
expect(wrapper.text()).toContain('No hay asistencias para mostrar');
});
})
describe('Local View Toggle Buttons', () => {
it('renders toggle buttons and reflects initial view from store (table)', async () => {
uiStoreMock.defaultViewAsistencias = 'table';
const wrapper = mountComponent();
await mockFetchAsistencias();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
expect(tableViewButton.exists()).toBe(true);
expect(cardViewButton.exists()).toBe(true);
// Check active class based on btnViewClass logic
// Active: bg-[var(--accent-color-asistencias)] text-white
// Inactive: bg-gray-200 text-gray-700
expect(tableViewButton.classes()).toContain('bg-[var(--accent-color-asistencias)]');
expect(cardViewButton.classes()).toContain('bg-gray-200');
});
it('renders toggle buttons and reflects initial view from store (card)', async () => {
uiStoreMock.defaultViewAsistencias = 'card';
const wrapper = mountComponent();
await mockFetchAsistencias();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-asistencias)]');
expect(tableViewButton.classes()).toContain('bg-gray-200');
});
it('switches to card view on button click and updates button styles, does not call global store action', async () => {
uiStoreMock.defaultViewAsistencias = 'table';
asistenciasStoreMock.asistencias = [{ id: 1, empleado: 'Test' }];
const wrapper = mountComponent();
await mockFetchAsistencias();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
await cardViewButton.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'CardAsistencia' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'TablaAsistencias' }).exists()).toBe(false);
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-asistencias)]');
expect(tableViewButton.classes()).toContain('bg-gray-200');
expect(mockSetDefaultViewAsistencias).not.toHaveBeenCalled();
});
it('switches back to table view on button click and updates button styles, does not call global store action', async () => {
uiStoreMock.defaultViewAsistencias = 'card'; // Start with card view
asistenciasStoreMock.asistencias = [{ id: 1, empleado: 'Test' }];
const wrapper = mountComponent();
await mockFetchAsistencias();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
// Initially card view is active
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-asistencias)]');
await tableViewButton.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'TablaAsistencias' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'CardAsistencia' }).exists()).toBe(false);
expect(tableViewButton.classes()).toContain('bg-[var(--accent-color-asistencias)]');
expect(cardViewButton.classes()).toContain('bg-gray-200');
expect(mockSetDefaultViewAsistencias).not.toHaveBeenCalled();
});
});
})

View File

@@ -19,19 +19,22 @@
</header>
<!-- selector de vista -->
<div class="mb-6 flex justify-end items-center space-x-3">
<span class="text-sm font-medium text-gray-700">Cambiar Vista:</span>
<button
@click="currentView = 'card'"
:class="btnClass('card')"
>
Tarjetas
</button>
<div class="mb-4 flex justify-end space-x-2">
<button
@click="currentView = 'table'"
:class="btnClass('table')"
:class="btnViewClass('table')"
aria-label="Table View"
title="Table View"
>
Tabla
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
</button>
<button
@click="currentView = 'card'"
:class="btnViewClass('card')"
aria-label="Card View"
title="Card View"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
</button>
</div>
@@ -80,6 +83,7 @@
import { ref, onMounted, computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router';
import { useUi } from '@/stores/useUi'; // Import useUi
import CardEmpleado from '@/components/empleados/cardEmpleado.vue';
import TablaEmpleados from '@/components/empleados/tablaEmpleados.vue';
@@ -87,7 +91,8 @@ import { useEmpleadosStore } from '@/stores/useEmpleados.js'; // ruta según tu
// --- refs locales ---
const router = useRouter();
const currentView = ref<'card' | 'table'>('card');
const ui = useUi(); // Access the ui store
const currentView = ref<'card' | 'table'>(ui.defaultViewEmpleados); // Initialize from store
const loading = ref(true);
const error = ref<string | null>(null);
@@ -99,14 +104,13 @@ const { empleados } = storeToRefs(empleadosStore);
const employees = empleados;
// --- helpers ---
const btnClass = (view: 'card' | 'table') => {
const baseClasses = 'px-4 py-2 rounded-md text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2';
if (currentView.value === view) {
// Active button uses accent color
return `${baseClasses} text-white shadow-sm view-toggle-active`;
// Removed btnClass as manual toggle buttons are removed
const btnViewClass = (viewType: 'card' | 'table') => {
const base = 'p-2 rounded-md transition-colors duration-150 ease-in-out';
if (currentView.value === viewType) {
return `${base} bg-[var(--accent-color-empleados)] text-white shadow-lg`;
}
// Inactive button uses secondary/gray styling
return `${baseClasses} bg-gray-200 text-gray-700 hover:bg-gray-300 focus:ring-gray-400`;
return `${base} bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600`;
};
// --- fetch inicial ---

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useUi } from '@/stores/useUi'
import { useEmpleadosStore } from '@/stores/useEmpleados'
import EmpleadosIndex from '../EmpleadosIndex.vue'
import TablaEmpleados from '@/components/empleados/tablaEmpleados.vue'
import CardEmpleado from '@/components/empleados/cardEmpleado.vue'
// Mock the child components to simplify testing and focus on EmpleadosIndex logic
vi.mock('@/components/empleados/tablaEmpleados.vue', () => ({
default: {
name: 'TablaEmpleados',
props: ['employees'],
template: '<div data-testid="tabla-empleados"></div>',
},
}))
vi.mock('@/components/empleados/cardEmpleado.vue', () => ({
default: {
name: 'CardEmpleado',
props: ['employee'],
template: '<div data-testid="card-empleado"></div>',
},
}))
// Mock the stores
// We need to define the mock functions before they are used by `vi.mock`
const mockSetDefaultViewEmpleados = vi.fn();
const mockFetchEmpleados = vi.fn();
vi.mock('@/stores/useUi', () => ({
useUi: vi.fn(() => ({
defaultViewEmpleados: 'table', // Default mock value
// Add any other state or actions needed by the component from useUi
setDefaultViewEmpleados: mockSetDefaultViewEmpleados,
})),
}))
vi.mock('@/stores/useEmpleados', () => ({
useEmpleadosStore: vi.fn(() => ({
empleados: [],
fetchEmpleados: mockFetchEmpleados,
// Add other state or actions if EmpleadosIndex uses them
})),
}))
describe('EmpleadosIndex.vue', () => {
let uiStoreMock
let empleadosStoreMock
beforeEach(() => {
setActivePinia(createPinia())
// Reset mocks and provide fresh instances for each test
mockFetchEmpleados.mockClear().mockResolvedValue([]); // Default to resolve successfully
mockSetDefaultViewEmpleados.mockClear();
// Get fresh instances of the mocked stores for manipulation in tests
// The actual `useUi` and `useEmpleadosStore` will be the vi.fn defined above
// Re-calling them ensures we can configure their return values per test suite if needed
// or rely on the default mock implementation.
uiStoreMock = useUi()
empleadosStoreMock = useEmpleadosStore()
})
const mountComponent = () => {
return mount(EmpleadosIndex, {
global: {
stubs: {
// While components are mocked via vi.mock, explicit stubs can be used for further control if needed
// For instance, if you didn't want to mock the entire module.
// 'TablaEmpleados': true,
// 'CardEmpleado': true,
}
},
})
}
it('fetches employees on mount', () => {
mountComponent()
expect(mockFetchEmpleados).toHaveBeenCalledTimes(1)
})
describe('View Rendering based on useUi store', () => {
it('renders TablaEmpleados when defaultViewEmpleados is "table"', async () => {
uiStoreMock.defaultViewEmpleados = 'table' // Set store state for this test
empleadosStoreMock.empleados = [{ id: 1, nombre: 'Test Employee' }] // Provide some data
const wrapper = mountComponent()
await wrapper.vm.$nextTick() // Wait for any reactivity updates
expect(wrapper.findComponent({ name: 'TablaEmpleados' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'TablaEmpleados' }).props('employees')).toEqual(empleadosStoreMock.empleados)
expect(wrapper.findComponent({ name: 'CardEmpleado' }).exists()).toBe(false)
})
it('renders CardEmpleado when defaultViewEmpleados is "card"', async () => {
uiStoreMock.defaultViewEmpleados = 'card' // Set store state for this test
empleadosStoreMock.empleados = [{ id: 1, nombre: 'Test Employee' }, { id: 2, nombre: 'Another Employee' }]
const wrapper = mountComponent()
await wrapper.vm.$nextTick()
const cardWrappers = wrapper.findAllComponents({ name: 'CardEmpleado' })
expect(cardWrappers.length).toBe(empleadosStoreMock.empleados.length)
expect(cardWrappers[0].props('employee')).toEqual(empleadosStoreMock.empleados[0])
expect(wrapper.findComponent({ name: 'TablaEmpleados' }).exists()).toBe(false)
})
it('renders no data message for table view when no employees exist', async () => {
uiStoreMock.defaultViewEmpleados = 'table';
empleadosStoreMock.empleados = []; // No data
const wrapper = mountComponent();
await wrapper.vm.$nextTick(); // allow loading state to pass
await wrapper.vm.$nextTick(); // allow conditional rendering based on data
expect(wrapper.findComponent({ name: 'TablaEmpleados' }).exists()).toBe(true); // Table component itself still renders
expect(wrapper.text()).toContain('No hay empleados para mostrar en la vista de tabla.');
});
it('renders no data message for card view when no employees exist', async () => {
uiStoreMock.defaultViewEmpleados = 'card';
empleadosStoreMock.empleados = []; // No data
const wrapper = mountComponent();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.findAllComponents({ name: 'CardEmpleado' }).length).toBe(0);
expect(wrapper.text()).toContain('No hay empleados para mostrar en la vista de tarjetas.');
});
})
describe('Local View Toggle Buttons', () => {
it('renders toggle buttons and reflects initial view from store (table)', async () => {
uiStoreMock.defaultViewEmpleados = 'table';
const wrapper = mountComponent();
// Wait for loading and reactivity
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
expect(tableViewButton.exists()).toBe(true);
expect(cardViewButton.exists()).toBe(true);
expect(tableViewButton.classes()).toContain('bg-[var(--accent-color-empleados)]');
expect(cardViewButton.classes()).toContain('bg-gray-200');
});
it('renders toggle buttons and reflects initial view from store (card)', async () => {
uiStoreMock.defaultViewEmpleados = 'card';
const wrapper = mountComponent();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-empleados)]');
expect(tableViewButton.classes()).toContain('bg-gray-200');
});
it('switches to card view on button click and updates button styles, does not call global store action', async () => {
uiStoreMock.defaultViewEmpleados = 'table';
empleadosStoreMock.empleados = [{ id: 1, nombre: 'Test' }];
const wrapper = mountComponent();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
await cardViewButton.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'CardEmpleado' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'TablaEmpleados' }).exists()).toBe(false);
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-empleados)]');
expect(tableViewButton.classes()).toContain('bg-gray-200');
expect(mockSetDefaultViewEmpleados).not.toHaveBeenCalled();
});
it('switches back to table view on button click and updates button styles, does not call global store action', async () => {
uiStoreMock.defaultViewEmpleados = 'card'; // Start with card view
empleadosStoreMock.empleados = [{ id: 1, nombre: 'Test' }];
const wrapper = mountComponent();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-empleados)]');
await tableViewButton.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'TablaEmpleados' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'CardEmpleado' }).exists()).toBe(false);
expect(tableViewButton.classes()).toContain('bg-[var(--accent-color-empleados)]');
expect(cardViewButton.classes()).toContain('bg-gray-200');
expect(mockSetDefaultViewEmpleados).not.toHaveBeenCalled();
});
});
})

View File

@@ -7,13 +7,23 @@
</button>
</header>
<div class="mb-6 flex justify-end items-center space-x-3">
<span class="text-sm font-medium text-gray-700">Cambiar Vista:</span>
<button @click="currentView = 'card'" :class="btnClass('card')">
Tarjetas
<!-- View Toggle Buttons -->
<div class="mb-4 flex justify-end space-x-2">
<button
@click="currentView = 'table'"
:class="btnViewClass('table')"
aria-label="Table View"
title="Table View"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
</button>
<button @click="currentView = 'table'" :class="btnClass('table')">
Tabla
<button
@click="currentView = 'card'"
:class="btnViewClass('card')"
aria-label="Card View"
title="Card View"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
</button>
</div>
@@ -60,18 +70,21 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { usePlanillasStore } from '../../stores/usePlanillas';
import { useUi } from '../../stores/useUi'; // Import useUi
import { useRouter } from 'vue-router';
import TablaPlanillas from '../../components/planillas/tablaPlanillas.vue'; // Corrected path
import CardPlanilla from '../../components/planillas/cardPlanilla.vue';
const planillasStore = usePlanillasStore();
const ui = useUi(); // Access the ui store
const router = useRouter();
const isLoading = ref(true); // Set to true initially
const errorLoading = ref(false);
const errorMessage = ref('');
const currentView = ref('table'); // Default to table view
// Initialize currentView from the store's default setting for planillas
const currentView = ref(ui.defaultViewPlanillas);
// Computed property to get planillas from the store
const planillasList = computed(() => planillasStore.planillas);
@@ -106,12 +119,12 @@ const handleEditPlanilla = (planillaId) => {
router.push({ name: 'planillas-edit', params: { id: planillaId } });
};
const btnClass = (view) => {
const baseClasses = 'px-4 py-2 rounded-md text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2';
if (currentView.value === view) {
return `${baseClasses} text-white shadow-sm view-toggle-active-planillas`;
const btnViewClass = (viewType) => {
const base = 'p-2 rounded-md transition-colors duration-150 ease-in-out';
if (currentView.value === viewType) {
return `${base} bg-[var(--accent-color-planillas)] text-white shadow-lg`;
}
return `${baseClasses} bg-gray-200 text-gray-700 hover:bg-gray-300 focus:ring-gray-400`;
return `${base} bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600`;
};
</script>

View File

@@ -0,0 +1,203 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useUi } from '@/stores/useUi'
import { usePlanillasStore } from '@/stores/usePlanillas'
import PlanillasIndex from '../PlanillasIndex.vue'
import TablaPlanillas from '@/components/planillas/tablaPlanillas.vue'
import CardPlanilla from '@/components/planillas/cardPlanilla.vue'
// Mock child components
vi.mock('@/components/planillas/tablaPlanillas.vue', () => ({
default: {
name: 'TablaPlanillas',
props: ['planillas'], // Match actual props
emits: ['edit'],
template: '<div data-testid="tabla-planillas"></div>',
},
}))
vi.mock('@/components/planillas/cardPlanilla.vue', () => ({
default: {
name: 'CardPlanilla',
props: ['planilla'], // Match actual props
emits: ['edit'],
template: '<div data-testid="card-planilla"></div>',
},
}))
// Mock stores
const mockSetDefaultViewPlanillas = vi.fn();
const mockFetchPlanillas = vi.fn();
vi.mock('@/stores/useUi', () => ({
useUi: vi.fn(() => ({
defaultViewPlanillas: 'table', // Default mock value
setDefaultViewPlanillas: mockSetDefaultViewPlanillas,
})),
}))
vi.mock('@/stores/usePlanillas', () => ({
usePlanillasStore: vi.fn(() => ({
planillas: [],
fetchPlanillas: mockFetchPlanillas,
})),
}))
describe('PlanillasIndex.vue', () => {
let uiStoreMock
let planillasStoreMock
beforeEach(() => {
setActivePinia(createPinia())
mockFetchPlanillas.mockClear().mockResolvedValue([])
mockSetDefaultViewPlanillas.mockClear()
uiStoreMock = useUi()
planillasStoreMock = usePlanillasStore()
})
const mountComponent = () => {
return mount(PlanillasIndex, {
global: {},
})
}
it('fetches planillas on mount', async () => {
mountComponent()
await mockFetchPlanillas(); // Ensure the promise from fetch resolves
expect(mockFetchPlanillas).toHaveBeenCalledTimes(1)
})
describe('View Rendering based on useUi store', () => {
it('renders TablaPlanillas when defaultViewPlanillas is "table"', async () => {
uiStoreMock.defaultViewPlanillas = 'table'
planillasStoreMock.planillas = [{ id: 1, periodo: '2023-01', total: 5000 }]
const wrapper = mountComponent()
await mockFetchPlanillas();
await wrapper.vm.$nextTick() // Wait for reactivity
await wrapper.vm.$nextTick() // Additional tick if loading state causes multiple updates
expect(wrapper.findComponent({ name: 'TablaPlanillas' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'TablaPlanillas' }).props('planillas')).toEqual(planillasStoreMock.planillas)
expect(wrapper.findComponent({ name: 'CardPlanilla' }).exists()).toBe(false)
})
it('renders CardPlanilla when defaultViewPlanillas is "card"', async () => {
uiStoreMock.defaultViewPlanillas = 'card'
planillasStoreMock.planillas = [{ id: 1, P1: 'P1' }, { id: 2, P2: 'P2' }]
const wrapper = mountComponent()
await mockFetchPlanillas();
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
const cardWrappers = wrapper.findAllComponents({ name: 'CardPlanilla' })
expect(cardWrappers.length).toBe(planillasStoreMock.planillas.length)
expect(cardWrappers[0].props('planilla')).toEqual(planillasStoreMock.planillas[0])
expect(wrapper.findComponent({ name: 'TablaPlanillas' }).exists()).toBe(false)
})
it('renders no data message for table view when no planillas exist', async () => {
uiStoreMock.defaultViewPlanillas = 'table';
planillasStoreMock.planillas = [];
const wrapper = mountComponent();
await mockFetchPlanillas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'TablaPlanillas' }).exists()).toBe(true);
expect(wrapper.text()).toContain('No hay planillas para mostrar');
});
it('renders no data message for card view when no planillas exist', async () => {
uiStoreMock.defaultViewPlanillas = 'card';
planillasStoreMock.planillas = [];
const wrapper = mountComponent();
await mockFetchPlanillas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.findAllComponents({ name: 'CardPlanilla' }).length).toBe(0);
expect(wrapper.text()).toContain('No hay planillas para mostrar');
});
})
describe('Local View Toggle Buttons', () => {
it('renders toggle buttons and reflects initial view from store (table)', async () => {
uiStoreMock.defaultViewPlanillas = 'table';
const wrapper = mountComponent();
await mockFetchPlanillas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
expect(tableViewButton.exists()).toBe(true);
expect(cardViewButton.exists()).toBe(true);
expect(tableViewButton.classes()).toContain('bg-[var(--accent-color-planillas)]');
expect(cardViewButton.classes()).toContain('bg-gray-200');
});
it('renders toggle buttons and reflects initial view from store (card)', async () => {
uiStoreMock.defaultViewPlanillas = 'card';
const wrapper = mountComponent();
await mockFetchPlanillas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-planillas)]');
expect(tableViewButton.classes()).toContain('bg-gray-200');
});
it('switches to card view on button click and updates button styles, does not call global store action', async () => {
uiStoreMock.defaultViewPlanillas = 'table';
planillasStoreMock.planillas = [{ id: 1, periodo: 'Test' }];
const wrapper = mountComponent();
await mockFetchPlanillas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
await cardViewButton.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'CardPlanilla' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'TablaPlanillas' }).exists()).toBe(false);
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-planillas)]');
expect(tableViewButton.classes()).toContain('bg-gray-200');
expect(mockSetDefaultViewPlanillas).not.toHaveBeenCalled();
});
it('switches back to table view on button click and updates button styles, does not call global store action', async () => {
uiStoreMock.defaultViewPlanillas = 'card'; // Start with card view
planillasStoreMock.planillas = [{ id: 1, periodo: 'Test' }];
const wrapper = mountComponent();
await mockFetchPlanillas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-planillas)]');
await tableViewButton.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'TablaPlanillas' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'CardPlanilla' }).exists()).toBe(false);
expect(tableViewButton.classes()).toContain('bg-[var(--accent-color-planillas)]');
expect(cardViewButton.classes()).toContain('bg-gray-200');
expect(mockSetDefaultViewPlanillas).not.toHaveBeenCalled();
});
});
})

View File

@@ -7,13 +7,23 @@
</button>
</header>
<div class="mb-6 flex justify-end items-center space-x-3">
<span class="text-sm font-medium text-gray-700">Cambiar Vista:</span>
<button @click="currentView = 'card'" :class="btnClass('card')">
Tarjetas
<!-- View Toggle Buttons -->
<div class="mb-4 flex justify-end space-x-2">
<button
@click="currentView = 'table'"
:class="btnViewClass('table')"
aria-label="Table View"
title="Table View"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
</button>
<button @click="currentView = 'table'" :class="btnClass('table')">
Tabla
<button
@click="currentView = 'card'"
:class="btnViewClass('card')"
aria-label="Card View"
title="Card View"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
</button>
</div>
@@ -60,18 +70,21 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useTareasStore } from '../../stores/useTareas';
import { useUi } from '../../stores/useUi'; // Import useUi
import { useRouter } from 'vue-router';
import TablaTareas from '../../components/tareas/tablaTareas.vue';
import CardTarea from '../../components/tareas/cardTarea.vue';
const tareasStore = useTareasStore();
const ui = useUi(); // Access the ui store
const router = useRouter();
const isLoading = ref(true);
const errorLoading = ref(false);
const errorMessage = ref('');
const currentView = ref('table'); // Default to table view
// Initialize currentView from the store's default setting for tareas
const currentView = ref(ui.defaultViewTareas);
const tareasList = computed(() => tareasStore.tareas);
@@ -100,12 +113,12 @@ const handleEditTarea = (tareaId) => {
router.push({ name: 'tareas-edit', params: { id: tareaId } });
};
const btnClass = (view) => {
const baseClasses = 'px-4 py-2 rounded-md text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2';
if (currentView.value === view) {
return `${baseClasses} text-white shadow-sm view-toggle-active-tareas`;
const btnViewClass = (viewType) => {
const base = 'p-2 rounded-md transition-colors duration-150 ease-in-out';
if (currentView.value === viewType) {
return `${base} bg-[var(--accent-color-tareas)] text-white shadow-lg`;
}
return `${baseClasses} bg-gray-200 text-gray-700 hover:bg-gray-300 focus:ring-gray-400`;
return `${base} bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600`;
};
</script>

View File

@@ -0,0 +1,203 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useUi } from '@/stores/useUi'
import { useTareasStore } from '@/stores/useTareas'
import TareasIndex from '../TareasIndex.vue'
import TablaTareas from '@/components/tareas/tablaTareas.vue'
import CardTarea from '@/components/tareas/cardTarea.vue'
// Mock child components
vi.mock('@/components/tareas/tablaTareas.vue', () => ({
default: {
name: 'TablaTareas',
props: ['tareas'], // Match actual props
emits: ['edit'],
template: '<div data-testid="tabla-tareas"></div>',
},
}))
vi.mock('@/components/tareas/cardTarea.vue', () => ({
default: {
name: 'CardTarea',
props: ['tarea'], // Match actual props
emits: ['edit'],
template: '<div data-testid="card-tarea"></div>',
},
}))
// Mock stores
const mockSetDefaultViewTareas = vi.fn();
const mockFetchTareas = vi.fn();
vi.mock('@/stores/useUi', () => ({
useUi: vi.fn(() => ({
defaultViewTareas: 'table', // Default mock value
setDefaultViewTareas: mockSetDefaultViewTareas,
})),
}))
vi.mock('@/stores/useTareas', () => ({
useTareasStore: vi.fn(() => ({
tareas: [],
fetchTareas: mockFetchTareas,
})),
}))
describe('TareasIndex.vue', () => {
let uiStoreMock
let tareasStoreMock
beforeEach(() => {
setActivePinia(createPinia())
mockFetchTareas.mockClear().mockResolvedValue([])
mockSetDefaultViewTareas.mockClear()
uiStoreMock = useUi()
tareasStoreMock = useTareasStore()
})
const mountComponent = () => {
return mount(TareasIndex, {
global: {},
})
}
it('fetches tareas on mount', async () => {
mountComponent()
await mockFetchTareas(); // Ensure the promise from fetch resolves
expect(mockFetchTareas).toHaveBeenCalledTimes(1)
})
describe('View Rendering based on useUi store', () => {
it('renders TablaTareas when defaultViewTareas is "table"', async () => {
uiStoreMock.defaultViewTareas = 'table'
tareasStoreMock.tareas = [{ id: 1, titulo: 'Test Task', completada: false }]
const wrapper = mountComponent()
await mockFetchTareas();
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'TablaTareas' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'TablaTareas' }).props('tareas')).toEqual(tareasStoreMock.tareas)
expect(wrapper.findComponent({ name: 'CardTarea' }).exists()).toBe(false)
})
it('renders CardTarea when defaultViewTareas is "card"', async () => {
uiStoreMock.defaultViewTareas = 'card'
tareasStoreMock.tareas = [{ id: 1, T1: 'T1' }, { id: 2, T2: 'T2' }]
const wrapper = mountComponent()
await mockFetchTareas();
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
const cardWrappers = wrapper.findAllComponents({ name: 'CardTarea' })
expect(cardWrappers.length).toBe(tareasStoreMock.tareas.length)
expect(cardWrappers[0].props('tarea')).toEqual(tareasStoreMock.tareas[0])
expect(wrapper.findComponent({ name: 'TablaTareas' }).exists()).toBe(false)
})
it('renders no data message for table view when no tareas exist', async () => {
uiStoreMock.defaultViewTareas = 'table';
tareasStoreMock.tareas = [];
const wrapper = mountComponent();
await mockFetchTareas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'TablaTareas' }).exists()).toBe(true);
expect(wrapper.text()).toContain('No hay tareas para mostrar');
});
it('renders no data message for card view when no tareas exist', async () => {
uiStoreMock.defaultViewTareas = 'card';
tareasStoreMock.tareas = [];
const wrapper = mountComponent();
await mockFetchTareas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.findAllComponents({ name: 'CardTarea' }).length).toBe(0);
expect(wrapper.text()).toContain('No hay tareas para mostrar');
});
})
describe('Local View Toggle Buttons', () => {
it('renders toggle buttons and reflects initial view from store (table)', async () => {
uiStoreMock.defaultViewTareas = 'table';
const wrapper = mountComponent();
await mockFetchTareas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
expect(tableViewButton.exists()).toBe(true);
expect(cardViewButton.exists()).toBe(true);
expect(tableViewButton.classes()).toContain('bg-[var(--accent-color-tareas)]');
expect(cardViewButton.classes()).toContain('bg-gray-200');
});
it('renders toggle buttons and reflects initial view from store (card)', async () => {
uiStoreMock.defaultViewTareas = 'card';
const wrapper = mountComponent();
await mockFetchTareas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-tareas)]');
expect(tableViewButton.classes()).toContain('bg-gray-200');
});
it('switches to card view on button click and updates button styles, does not call global store action', async () => {
uiStoreMock.defaultViewTareas = 'table';
tareasStoreMock.tareas = [{ id: 1, titulo: 'Test' }];
const wrapper = mountComponent();
await mockFetchTareas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
await cardViewButton.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'CardTarea' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'TablaTareas' }).exists()).toBe(false);
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-tareas)]');
expect(tableViewButton.classes()).toContain('bg-gray-200');
expect(mockSetDefaultViewTareas).not.toHaveBeenCalled();
});
it('switches back to table view on button click and updates button styles, does not call global store action', async () => {
uiStoreMock.defaultViewTareas = 'card'; // Start with card view
tareasStoreMock.tareas = [{ id: 1, titulo: 'Test' }];
const wrapper = mountComponent();
await mockFetchTareas();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const cardViewButton = wrapper.find('button[aria-label="Card View"]');
const tableViewButton = wrapper.find('button[aria-label="Table View"]');
expect(cardViewButton.classes()).toContain('bg-[var(--accent-color-tareas)]');
await tableViewButton.trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'TablaTareas' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'CardTarea' }).exists()).toBe(false);
expect(tableViewButton.classes()).toContain('bg-[var(--accent-color-tareas)]');
expect(cardViewButton.classes()).toContain('bg-gray-200');
expect(mockSetDefaultViewTareas).not.toHaveBeenCalled();
});
});
})