Merge pull request #24 from josedario87/feature/nucleo-data-card

Feature/nucleo data card
This commit is contained in:
josedario87
2025-06-03 13:49:50 -06:00
committed by GitHub
6 changed files with 373 additions and 231 deletions

View File

@@ -1,41 +1,30 @@
<template>
<div :style="{ backgroundColor: ui.tableBgColorAsistencias }" class="shadow-md rounded-lg p-4 md:p-6 m-2 border border-gray-200 hover:shadow-lg transition-shadow duration-300 ease-in-out flex flex-col">
<div class="flex justify-between items-center mb-3 md:mb-4 pb-2 md:pb-3 border-b border-gray-100">
<h4 class="text-lg md:text-xl font-semibold" :style="{ color: 'var(--accent-color-asistencias)' }">Asistencia ID: {{ asistencia.id }}</h4>
<NucleoDataCard
:title="`Asistencia ID: ${asistencia.id}`"
:status="asistencia.estado"
:fields="cardFields"
:accent-color="ui.accentColorAsistencias"
:background-color="ui.tableBgColorAsistencias"
:observation="asistencia.observacion"
@edit="editAsistencia"
@delete="confirmDeleteAsistencia"
>
<template #header>
<div class="flex items-center justify-between w-full">
<h2 class="text-xl font-semibold text-gray-800" :style="{ color: ui.accentColorAsistencias }">Asistencia ID: {{ asistencia.id }}</h2>
<span :class="['px-2 py-1 text-xs font-bold text-white rounded-full', getStatusClass(asistencia.estado)]">
{{ asistencia.estado || 'N/A' }}
</span>
</div>
<div class="text-sm text-gray-700 space-y-2">
<p><strong class="font-medium text-gray-900">Empleado ID:</strong> {{ asistencia.empleado_id }}</p>
<p><strong class="font-medium text-gray-900">Entrada:</strong> {{ formatDateTime(asistencia.entrada) }}</p>
<p><strong class="font-medium text-gray-900">Salida:</strong> {{ asistencia.salida ? formatDateTime(asistencia.salida) : 'No registrada' }}</p>
<p v-if="asistencia.observacion" class="italic text-gray-600 bg-gray-50 p-2 border-l-3 rounded" :style="{ borderColor: 'var(--accent-color-asistencias)' }">
<strong class="font-medium text-gray-900">Observación:</strong> {{ asistencia.observacion }}
</p>
</div>
<div class="mt-auto pt-3 md:pt-4 flex justify-end space-x-2 md:space-x-3">
<button
@click="editAsistencia"
class="px-3 py-1 md:px-4 md:py-2 text-xs md:text-sm font-medium rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-white"
:style="{ backgroundColor: 'var(--accent-color-asistencias)', borderColor: 'var(--accent-color-asistencias)' }"
@mouseover="buttonHover($event, true, 'var(--accent-color-asistencias)')"
@mouseleave="buttonHover($event, false, 'var(--accent-color-asistencias)')"
:class="`focus:ring-[var(--accent-color-asistencias)]`"
>Editar</button>
<button
@click="confirmDeleteAsistencia"
class="px-3 py-1 md:px-4 md:py-2 text-xs md:text-sm font-medium rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 bg-red-600 hover:bg-red-700 text-white focus:ring-red-500"
>Eliminar</button>
</div>
</div>
</template>
</NucleoDataCard>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { defineProps, defineEmits, computed } from 'vue';
import { useAsistenciasStore } from '../../stores/useAsistencias';
import { useUi } from '../../stores/useUi.js';
import { computed } from 'vue';
import NucleoDataCard from '../ui/NucleoDataCard.vue';
const ui = useUi();
@@ -53,15 +42,23 @@ const asistenciasStore = useAsistenciasStore();
const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return 'N/A';
const date = new Date(dateTimeString);
return date.toLocaleString('es-HN', { timeZone: 'America/Tegucigalpa', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
// Consistent formatting
return date.toLocaleString('es-HN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: true });
};
const cardFields = computed(() => [
{ label: 'Empleado ID', value: props.asistencia.empleado_id },
{ label: 'Entrada', value: formatDateTime(props.asistencia.entrada) },
{ label: 'Salida', value: props.asistencia.salida ? formatDateTime(props.asistencia.salida) : 'No registrada' },
]);
const editAsistencia = () => {
emit('edit', props.asistencia.id);
};
const confirmDeleteAsistencia = () => {
if (confirm(`¿Está seguro de que desea eliminar la asistencia "${props.asistencia.id}" (ID: ${props.asistencia.id})?`)) {
// Consider using a global notification/dialog system if available
if (confirm(`¿Está seguro de que desea eliminar la asistencia ID: ${props.asistencia.id}?`)) {
deleteAsistenciaInternal();
}
};
@@ -69,48 +66,34 @@ const confirmDeleteAsistencia = () => {
const deleteAsistenciaInternal = async () => {
try {
await asistenciasStore.deleteAsistencia(props.asistencia.id);
// Optionally, show a success notification
} catch (error) {
console.error('Error deleting asistencia:', error);
// Optionally, show an error notification
alert('Ocurrió un error al eliminar la asistencia.');
}
};
const getStatusClass = (status) => {
if (!status) return 'bg-gray-400'; // Default for N/A
if (!status) return 'bg-gray-400 text-white'; // Default for N/A, ensuring text is visible
const statusNormalized = status.toLowerCase().replace(/\s+/g, '-');
switch (statusNormalized) {
case 'pendiente': return 'bg-yellow-500 text-gray-800';
case 'presente':
case 'confirmada': return 'bg-green-500';
case 'ausente': return 'bg-red-500';
case 'justificada': return 'bg-blue-500';
case 'confirmada': return 'bg-green-500 text-white';
case 'ausente': return 'bg-red-500 text-white';
case 'justificada': return 'bg-blue-500 text-white';
case 'cancelada':
case 'anulada': return 'bg-gray-500';
default: return 'bg-gray-400';
}
};
const buttonHover = (event, isHovering, colorVar) => {
if (isHovering) {
event.target.style.filter = 'brightness(90%)';
} else {
event.target.style.filter = 'brightness(100%)';
case 'anulada': return 'bg-gray-500 text-white';
default: return 'bg-gray-400 text-white';
}
};
</script>
<style scoped>
/* Minimal scoped styles, mainly for complex status colors if not handled by Tailwind directly */
/* Or for :style bindings if preferred for dynamic properties like accent colors */
/* The border-l-3 for observation uses a utility-like class,
but Tailwind doesn't have border-left-width: 3px by default.
You could add this to your tailwind.config.js if used often:
theme: { extend: { borderWidth: { '3': '3px' } } }
Then use class `border-l-3`. For now, inline style is fine.
/* Scoped styles can be kept if they target elements within slots that NucleoDataCard doesn't style.
For this refactor, assuming NucleoDataCard and Tailwind handle most styling.
The .border-l-3 class is no longer used as observation styling is handled by NucleoDataCard.
*/
.border-l-3 {
border-left-width: 3px;
}
</style>

View File

@@ -1,57 +1,55 @@
<template>
<div :style="{ backgroundColor: ui.tableBgColorEmpleados }" class="shadow-md rounded-lg p-4 md:p-6 m-2 border border-gray-200 hover:shadow-lg transition-shadow duration-300 ease-in-out flex flex-col">
<div class="flex items-center mb-3 md:mb-4 pb-2 md:pb-3 border-b border-gray-100">
<NucleoDataCard
:title="employee.name"
:accent-color="ui.accentColorEmpleados"
:background-color="ui.tableBgColorEmpleados"
:avatar-url="employee.avatar_url || 'https://via.placeholder.com/150'"
@edit="handleEdit"
@delete="confirmDeleteEmpleado"
>
<template #header>
<div class="flex items-center w-full">
<img
:src="employee.avatar_url || 'https://via.placeholder.com/150'"
alt="Avatar del empleado"
class="w-16 h-16 rounded-full mr-4 border-2 object-cover"
:style="{ borderColor: 'var(--accent-color-empleados)' }"
:style="{ borderColor: ui.accentColorEmpleados }"
/>
<div>
<h2 class="text-lg md:text-xl font-semibold" :style="{ color: 'var(--accent-color-empleados)' }">{{ employee.name }}</h2>
<div class="flex-grow">
<h2 class="text-lg md:text-xl font-semibold" :style="{ color: ui.accentColorEmpleados }">{{ employee.name }}</h2>
<p class="text-xs text-gray-500">ID: {{ employee.id }}</p>
</div>
</div>
</template>
<template #body>
<div class="text-sm text-gray-700 space-y-2">
<p v-if="employee.telefono" class="flex items-center">
<svg class="w-5 h-5 mr-2 shrink-0" :style="{ color: 'var(--accent-color-empleados)' }" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.308 1.154a11.042 11.042 0 005.516 5.516l1.154-2.308a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path></svg>
<svg class="w-5 h-5 mr-2 shrink-0" :style="{ color: ui.accentColorEmpleados }" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.308 1.154a11.042 11.042 0 005.516 5.516l1.154-2.308a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path></svg>
<strong class="font-medium text-gray-900">Teléfono:</strong>&nbsp;<span>{{ employee.telefono }}</span>
</p>
<p v-if="employee.ubicacion" class="flex items-center">
<svg class="w-5 h-5 mr-2 shrink-0" :style="{ color: 'var(--accent-color-empleados)' }" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<svg class="w-5 h-5 mr-2 shrink-0" :style="{ color: ui.accentColorEmpleados }" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<strong class="font-medium text-gray-900">Ubicación:</strong>&nbsp;<span>{{ employee.ubicacion }}</span>
</p>
<p v-if="employee.cedula" class="flex items-center">
<svg class="w-5 h-5 mr-2 shrink-0" :style="{ color: 'var(--accent-color-empleados)' }" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 012-2h2a2 2 0 012 2v1m-4 0h4m-6 10v-5m0 5v0z"></path></svg>
<svg class="w-5 h-5 mr-2 shrink-0" :style="{ color: ui.accentColorEmpleados }" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 012-2h2a2 2 0 012 2v1m-4 0h4m-6 10v-5m0 5v0z"></path></svg>
<strong class="font-medium text-gray-900">Cédula:</strong>&nbsp;<span>{{ employee.cedula }}</span>
</p>
<p v-if="employee.grupo_estudio" class="flex items-center">
<svg class="w-5 h-5 mr-2 shrink-0" :style="{ color: 'var(--accent-color-empleados)' }" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 14l9-5-9-5-9 5 9 5z"></path><path d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-5.998 12.083 12.083 0 01.665-6.479L12 14z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l9-5-9-5-9 5 9 5zm0 0v3.945m0-3.945L6.161 10.58M17.839 10.58L12 14m5.839-3.42L12 14m0 0l6.161 3.42m-6.161-3.42L5.839 14.002"></path></svg>
<svg class="w-5 h-5 mr-2 shrink-0" :style="{ color: ui.accentColorEmpleados }" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 14l9-5-9-5-9 5 9 5z"></path><path d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-5.998 12.083 12.083 0 01.665-6.479L12 14z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l9-5-9-5-9 5 9 5zm0 0v3.945m0-3.945L6.161 10.58M17.839 10.58L12 14m5.839-3.42L12 14m0 0l6.161 3.42m-6.161-3.42L5.839 14.002"></path></svg>
<strong class="font-medium text-gray-900">Grupo Estudio:</strong>&nbsp;<span>{{ employee.grupo_estudio }}</span>
</p>
</div>
<div class="mt-auto pt-3 md:pt-4 flex justify-end space-x-2 md:space-x-3">
<button
@click="handleEdit"
class="px-3 py-1 md:px-4 md:py-2 text-xs md:text-sm font-medium rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-white"
:style="{ backgroundColor: 'var(--accent-color-empleados)', borderColor: 'var(--accent-color-empleados)' }"
@mouseover="buttonHover($event, true)"
@mouseleave="buttonHover($event, false)"
:class="`focus:ring-[var(--accent-color-empleados)]`"
>Editar</button>
<button
@click="confirmDeleteEmpleado"
class="px-3 py-1 md:px-4 md:py-2 text-xs md:text-sm font-medium rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 bg-red-600 hover:bg-red-700 text-white focus:ring-red-500"
>Eliminar</button>
</div>
</div>
</template>
</NucleoDataCard>
</template>
<script setup lang="ts">
import { PropType } from 'vue'
import { useRouter } from 'vue-router'
import { defineProps, defineEmits } from 'vue'; // Removed PropType, useRouter as they are not used
import { useUi } from '../../stores/useUi.js';
import { useEmpleadosStore } from '../../stores/useEmpleados'; // Added import
import NucleoDataCard from '../ui/NucleoDataCard.vue';
const ui = useUi();
const emit = defineEmits(['edit']);
@@ -77,30 +75,22 @@ const confirmDeleteEmpleado = () => {
const deleteEmpleado = async () => {
try {
await empleadosStore.deleteEmpleado(props.employee.id);
// Optionally, show a success notification or emit an event if needed,
// though typically the list will update reactively from the store.
// Optionally, show a success notification
} catch (error) {
console.error('Error deleting employee:', error);
alert('Ocurrió un error al eliminar el empleado.');
}
};
const buttonHover = (event, isHovering) => {
const target = event.target;
if (isHovering) {
target.style.filter = 'brightness(90%)';
} else {
target.style.filter = 'brightness(100%)';
}
};
// buttonHover function removed as it's no longer used
</script>
<style scoped>
/* Minimal scoped styles */
.shrink-0 {
/* Scoped styles can be kept if they target elements within slots that NucleoDataCard doesn't style.
.shrink-0 might be useful if icons/elements in slots need it and it's not covered by Tailwind classes used.
.rounded-full with object-fit is good practice for avatars; NucleoDataCard's default avatar is also rounded.
The custom avatar styling (size, border) is now handled directly in the #header slot.
*/
.shrink-0 { /* Retained if needed by SVGs in slots, though Tailwind's `shrink-0` class can be used directly in template */
flex-shrink: 0;
}
.rounded-full {
object-fit: cover; /* Ensures the avatar image covers the area without distortion */
}
</style>

View File

@@ -1,39 +1,29 @@
<template>
<div :style="{ backgroundColor: ui.tableBgColorPlanillas }" class="shadow-md rounded-lg p-4 md:p-6 m-2 border border-gray-200 hover:shadow-lg transition-shadow duration-300 ease-in-out flex flex-col">
<div class="flex justify-between items-center mb-3 md:mb-4 pb-2 md:pb-3 border-b border-gray-100">
<h4 class="text-lg md:text-xl font-semibold" :style="{ color: 'var(--accent-color-planillas)' }">Planilla ID: {{ planilla.id }}</h4>
<NucleoDataCard
:title="`Planilla ID: ${planilla.id}`"
:status="planilla.estado"
:fields="cardFields"
:accent-color="ui.accentColorPlanillas"
:background-color="ui.tableBgColorPlanillas"
@edit="editPlanilla"
@delete="confirmDeletePlanilla"
>
<template #header>
<div class="flex items-center justify-between w-full">
<h2 class="text-xl font-semibold" :style="{ color: ui.accentColorPlanillas }">Planilla ID: {{ planilla.id }}</h2>
<span :class="['px-2 py-1 text-xs font-bold text-white rounded-full', getStatusClass(planilla.estado)]">
{{ planilla.estado || 'N/A' }}
</span>
</div>
<div class="text-sm text-gray-700 space-y-2">
<p><strong class="font-medium text-gray-900">Título:</strong> {{ planilla.titulo }}</p>
<p><strong class="font-medium text-gray-900">Empleado ID:</strong> {{ planilla.empleado_id }}</p>
<p><strong class="font-medium text-gray-900">Desde:</strong> {{ formatDate(planilla.fecha_desde) }}</p>
<p><strong class="font-medium text-gray-900">Hasta:</strong> {{ formatDate(planilla.fecha_hasta) }}</p>
<p><strong class="font-medium text-gray-900">Total:</strong> {{ formatCurrency(planilla.total) }}</p>
</div>
<div class="mt-auto pt-3 md:pt-4 flex justify-end space-x-2 md:space-x-3">
<button
@click="editPlanilla"
class="px-3 py-1 md:px-4 md:py-2 text-xs md:text-sm font-medium rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-white"
:style="{ backgroundColor: 'var(--accent-color-planillas)', borderColor: 'var(--accent-color-planillas)' }"
@mouseover="buttonHover($event, true)"
@mouseleave="buttonHover($event, false)"
:class="`focus:ring-[var(--accent-color-planillas)]`"
>Editar</button>
<button
@click="confirmDeletePlanilla"
class="px-3 py-1 md:px-4 md:py-2 text-xs md:text-sm font-medium rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 bg-red-600 hover:bg-red-700 text-white focus:ring-red-500"
>Eliminar</button>
</div>
</div>
</template>
</NucleoDataCard>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { defineProps, defineEmits, computed } from 'vue';
import { usePlanillasStore } from '../../stores/usePlanillas';
import { useUi } from '../../stores/useUi.js';
import NucleoDataCard from '../ui/NucleoDataCard.vue';
const ui = useUi();
@@ -58,21 +48,31 @@ const formatCurrency = (value) => {
if (value == null) return 'N/A';
return Number(value).toLocaleString('es-PY', {
style: 'currency',
currency: 'PYG'
currency: 'PYG',
minimumFractionDigits: 0, // Ensure no decimals for PYG if that's standard
maximumFractionDigits: 0,
});
};
const cardFields = computed(() => [
{ label: 'Título', value: props.planilla.titulo },
{ label: 'Empleado ID', value: props.planilla.empleado_id },
{ label: 'Desde', value: formatDate(props.planilla.fecha_desde) },
{ label: 'Hasta', value: formatDate(props.planilla.fecha_hasta) },
{ label: 'Total', value: formatCurrency(props.planilla.total) },
]);
const editPlanilla = () => {
emit('edit', props.planilla.id);
};
const confirmDeletePlanilla = () => {
if (confirm(`¿Está seguro de que desea eliminar la planilla "${props.planilla.titulo}" (ID: ${props.planilla.id})?`)) {
deletePlanilla();
deletePlanillaInternal();
}
};
const deletePlanilla = async () => {
const deletePlanillaInternal = async () => {
try {
await planillasStore.deletePlanilla(props.planilla.id);
} catch (error) {
@@ -82,28 +82,19 @@ const deletePlanilla = async () => {
};
const getStatusClass = (status) => {
if (!status) return 'bg-gray-400';
if (!status) return 'bg-gray-400 text-white';
const statusNormalized = status.toLowerCase().replace(/\s+/g, '-');
switch (statusNormalized) {
case 'pagado': return 'bg-green-500';
case 'pagado': return 'bg-green-500 text-white';
case 'pendiente': return 'bg-yellow-500 text-gray-800';
case 'anulado': return 'bg-red-500';
case 'borrador': return 'bg-gray-500';
default: return 'bg-gray-400';
case 'anulado': return 'bg-red-500 text-white';
case 'borrador': return 'bg-gray-500 text-white';
default: return 'bg-gray-400 text-white';
}
};
const buttonHover = (event, isHovering) => {
if (isHovering) {
event.target.style.filter = 'brightness(90%)';
} else {
event.target.style.filter = 'brightness(100%)';
}
};
// buttonHover removed
</script>
<style scoped>
/* All layout and most styling is now handled by Tailwind utility classes. */
/* Scoped styles can be used for very specific cases not covered by Tailwind, if any. */
/* Scoped styles can be removed or adjusted if not needed after refactor. */
</style>

View File

@@ -1,42 +1,30 @@
<template>
<div :style="{ backgroundColor: ui.tableBgColorTareas }" class="shadow-md rounded-lg p-4 md:p-6 m-2 border border-gray-200 hover:shadow-lg transition-shadow duration-300 ease-in-out flex flex-col">
<div class="flex justify-between items-center mb-3 md:mb-4 pb-2 md:pb-3 border-b border-gray-100">
<h4 class="text-lg md:text-xl font-semibold" :style="{ color: 'var(--accent-color-tareas)' }">Tarea ID: {{ tarea.id }}</h4>
<NucleoDataCard
:title="`Tarea ID: ${tarea.id}`"
:status="tarea.estado"
:fields="cardFields"
:accent-color="ui.accentColorTareas"
:background-color="ui.tableBgColorTareas"
:observation="tarea.observacion"
@edit="editTarea"
@delete="confirmDeleteTarea"
>
<template #header>
<div class="flex items-center justify-between w-full">
<h2 class="text-xl font-semibold" :style="{ color: ui.accentColorTareas }">Tarea ID: {{ tarea.id }}</h2>
<span :class="['px-2 py-1 text-xs font-bold text-white rounded-full', getStatusClass(tarea.estado)]">
{{ tarea.estado || 'N/A' }}
</span>
</div>
<div class="text-sm text-gray-700 space-y-2">
<p><strong class="font-medium text-gray-900">Título:</strong> {{ tarea.titulo }}</p>
<p><strong class="font-medium text-gray-900">Empleado ID:</strong> {{ tarea.empleado_id }}</p>
<p><strong class="font-medium text-gray-900">Fecha:</strong> {{ formatDate(tarea.fecha) }}</p>
<p><strong class="font-medium text-gray-900">Tipo:</strong> {{ tarea.tipo || 'N/A' }}</p>
<p v-if="tarea.precio != null"><strong class="font-medium text-gray-900">Precio:</strong> {{ formatCurrency(tarea.precio) }}</p>
<p v-if="tarea.observacion" class="italic text-gray-600 bg-gray-50 p-2 border-l-3 rounded" :style="{ borderColor: 'var(--accent-color-tareas)' }">
<strong class="font-medium text-gray-900">Observación:</strong> {{ tarea.observacion }}
</p>
</div>
<div class="mt-auto pt-3 md:pt-4 flex justify-end space-x-2 md:space-x-3">
<button
@click="editTarea"
class="px-3 py-1 md:px-4 md:py-2 text-xs md:text-sm font-medium rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 text-white"
:style="{ backgroundColor: 'var(--accent-color-tareas)', borderColor: 'var(--accent-color-tareas)' }"
@mouseover="buttonHover($event, true)"
@mouseleave="buttonHover($event, false)"
:class="`focus:ring-[var(--accent-color-tareas)]`"
>Editar</button>
<button
@click="confirmDeleteTarea"
class="px-3 py-1 md:px-4 md:py-2 text-xs md:text-sm font-medium rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 bg-red-600 hover:bg-red-700 text-white focus:ring-red-500"
>Eliminar</button>
</div>
</div>
</template>
</NucleoDataCard>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { defineProps, defineEmits, computed } from 'vue';
import { useTareasStore } from '../../stores/useTareas';
import { useUi } from '../../stores/useUi.js';
import NucleoDataCard from '../ui/NucleoDataCard.vue';
const ui = useUi();
@@ -54,17 +42,32 @@ const tareasStore = useTareasStore();
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleString('es-HN', { timeZone: 'America/Tegucigalpa', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return date.toLocaleString('es-HN', { timeZone: 'America/Tegucigalpa', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: true });
};
const formatCurrency = (value) => {
if (value == null) return '';
if (value == null) return ''; // Return empty string if no price, so label doesn't show with 'N/A'
return Number(value).toLocaleString('es-PY', {
style: 'currency',
currency: 'PYG',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
};
const cardFields = computed(() => {
const fields = [
{ label: 'Título', value: props.tarea.titulo },
{ label: 'Empleado ID', value: props.tarea.empleado_id },
{ label: 'Fecha', value: formatDate(props.tarea.fecha) },
{ label: 'Tipo', value: props.tarea.tipo || 'N/A' },
];
if (props.tarea.precio != null) {
fields.push({ label: 'Precio', value: formatCurrency(props.tarea.precio) });
}
return fields;
});
const editTarea = () => {
emit('edit', props.tarea.id);
};
@@ -85,35 +88,25 @@ const deleteTareaInternal = async () => {
};
const getStatusClass = (status) => {
if (!status) return 'bg-gray-400';
if (!status) return 'bg-gray-400 text-white';
const statusNormalized = status.toLowerCase().replace(/\s+/g, '-');
switch (statusNormalized) {
case 'pendiente': return 'bg-yellow-500 text-gray-800';
case 'realizada':
case 'completada':
case 'hecho': return 'bg-green-500';
case 'en-progreso': return 'bg-blue-500';
case 'hecho': return 'bg-green-500 text-white';
case 'en-progreso': return 'bg-blue-500 text-white';
case 'anulada':
case 'cancelada': return 'bg-red-500';
case 'archivada': return 'bg-gray-500';
default: return 'bg-gray-400';
case 'cancelada': return 'bg-red-500 text-white';
case 'archivada': return 'bg-gray-500 text-white';
default: return 'bg-gray-400 text-white';
}
};
const buttonHover = (event, isHovering) => {
if (isHovering) {
event.target.style.filter = 'brightness(90%)';
} else {
event.target.style.filter = 'brightness(100%)';
}
};
// buttonHover removed
</script>
<style scoped>
/* Minimal scoped styles, mainly for complex status colors if not handled by Tailwind directly */
/* Or for :style bindings if preferred for dynamic properties like accent colors */
.border-l-3 {
border-left-width: 3px;
}
/* Scoped styles can be removed if NucleoDataCard's observation styling is acceptable.
The .border-l-3 class is for the old observation styling.
*/
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div
class="rounded-lg shadow-md p-6 border-t-4"
:style="{ backgroundColor: backgroundColor, borderTopColor: accentColor }"
>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<slot name="header">
<div class="flex items-center">
<img v-if="avatarUrl" :src="avatarUrl" alt="Avatar" class="w-10 h-10 rounded-full mr-3">
<h2 class="text-xl font-semibold text-gray-800">{{ title }}</h2>
</div>
<span v-if="status" :class="`px-2 py-1 text-xs font-semibold leading-tight text-${accentColor}-700 bg-${accentColor}-100 rounded-full`">{{ status }}</span>
</slot>
</div>
<!-- Body -->
<div class="mb-4">
<slot name="body">
<div v-for="(field, index) in fields" :key="index" class="mb-2">
<p class="text-sm text-gray-600 font-medium">{{ field.label }}:</p>
<p class="text-sm text-gray-800">{{ field.value }}</p>
</div>
<div v-if="observation" class="mt-4">
<p class="text-sm text-gray-600 font-medium">Observation:</p>
<p class="text-sm text-gray-800">{{ observation }}</p>
</div>
</slot>
</div>
<!-- Footer -->
<div class="flex justify-end items-center">
<slot name="footer">
<slot name="actions"></slot>
<button v-if="showEditButton" @click="$emit('edit')" class="text-sm text-blue-500 hover:text-blue-700 font-medium mr-4">Edit</button>
<button v-if="showDeleteButton" @click="$emit('delete')" class="text-sm text-red-500 hover:text-red-700 font-medium">Delete</button>
</slot>
</div>
</div>
</template>
<script>
export default {
name: 'NucleoDataCard',
props: {
title: {
type: String,
required: true,
},
status: {
type: String,
default: '',
},
fields: {
type: Array,
default: () => [], // Example: [{ label: 'ID', value: '123' }]
},
accentColor: {
type: String,
default: '#808080', // Default to gray hex
},
backgroundColor: {
type: String,
default: '#FFFFFF', // Default to white hex
},
avatarUrl: {
type: String,
default: '',
},
observation: {
type: String,
default: '',
},
showEditButton: {
type: Boolean,
default: true,
},
showDeleteButton: {
type: Boolean,
default: true,
},
},
emits: ['edit', 'delete'],
};
</script>
<style scoped>
/* Scoped styles can be added here if needed, Tailwind CSS is preferred for general styling */
</style>

View File

@@ -0,0 +1,96 @@
# UI Components
This directory contains general-purpose UI components used throughout the application.
## NucleoDataCard.vue
A flexible and centralized card component for displaying data entities.
### Purpose
The `NucleoDataCard` component provides a standardized way to display information in a card format. It's designed to be adaptable to various data types and styling requirements through props and slots. It supports a header (with optional avatar, title, and status), a body for displaying data fields and an optional observation section, and a footer with action buttons.
### Props
| Prop | Type | Default | Description |
|---------------------|---------|-------------|-----------------------------------------------------------------------------|
| `title` | String | _Required_ | The main title of the card, typically displayed in the header. |
| `status` | String | `''` | A status string to be displayed as a badge in the header. |
| `fields` | Array | `[]` | An array of objects (`{ label: String, value: String }`) for data rows. Each object represents a piece of data to be displayed with a label and its value. |
| `accentColor` | String | `'#808080'` | Hex color code for the card's top border. |
| `backgroundColor` | String | `'#FFFFFF'` | Hex color code for the card's background. |
| `avatarUrl` | String | `''` | URL for an avatar image to be displayed in the header. |
| `observation` | String | `''` | A text string for an observation section displayed below the fields. |
| `showEditButton` | Boolean | `true` | Whether to display the default Edit button in the footer. |
| `showDeleteButton` | Boolean | `true` | Whether to display the default Delete button in the footer. |
*Note: `accentColor` and `backgroundColor` are applied using inline styles. The default status badge styling in the header slot might not work as expected with hex `accentColor` values for its default Tailwind classes; use the `header` slot for custom status styling if needed.*
### Slots
- **`header`**: Allows custom content or layout for the card header. If used, it overrides the default header structure which includes the avatar, title, and status badge.
- **`body`**: Allows complete customization of the main card content area. If used, you are responsible for rendering the content that would normally go here, such as iterating through the `fields` prop and displaying the `observation` prop. The default content of this slot handles the rendering of `fields` and `observation`.
- **`footer`**: Allows custom content to be injected at the beginning of the card footer, before the `actions` slot and default Edit/Delete buttons.
- **`actions`**: Allows adding custom buttons or other interactive elements to the footer. These are rendered before the default Edit and Delete buttons if they are visible.
### Events
- **`edit`**: Emitted with no payload when the default Edit button (if shown) is clicked.
- **`delete`**: Emitted with no payload when the default Delete button (if shown) is clicked.
### Example Usage
```vue
<template>
<NucleoDataCard
title="John Doe"
status="Active"
:fields="userDetails"
accentColor="#3498db"
backgroundColor="#f9f9f9"
avatarUrl="path/to/avatar.jpg"
observation="This user has been active for 3 years."
:showEditButton="true"
:showDeleteButton="true"
@edit="onEditUser"
@delete="onDeleteUser"
>
<template #actions>
<button @click="viewMoreDetails" class="px-3 py-1 text-sm text-white bg-blue-500 rounded hover:bg-blue-600">
View More
</button>
</template>
</NucleoDataCard>
</template>
<script setup>
import NucleoDataCard from './NucleoDataCard.vue'; // Adjust path as needed
import { ref } from 'vue';
const userDetails = ref([
{ label: 'Email', value: 'john.doe@example.com' },
{ label: 'Department', value: 'Engineering' },
{ label: 'Role', value: 'Software Developer' },
]);
const onEditUser = () => {
console.log('Edit user event triggered');
// Add logic to handle user editing
};
const onDeleteUser = () => {
console.log('Delete user event triggered');
// Add logic to handle user deletion
};
const viewMoreDetails = () => {
console.log('View more details action triggered');
// Add logic for custom action
};
</script>
<style scoped>
/* Example of additional styling if needed for custom elements via slots */
/* Tailwind CSS classes are generally preferred */
</style>
```