Files
SIB/frontend/src/views/AdminDrivers.vue
Hanzo_dev 0d95850529
Some checks failed
Deploy to Coolify / Quality Assurance (push) Failing after 17s
Deploy to Coolify / Deploy to Coolify (push) Has been skipped
feat: redesign admin panel with modern UI and usability improvements
- Redesign AdminPanel.vue with color-coded section navigation cards
- Create AdminPageHeader.vue shared component with back-nav and action slot
- Redesign AdminDrivers.vue with modern taxi cards and bottom-sheet modal
- Redesign AdminReports.vue with stats summary and color-coded report cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:19:36 -05:00

920 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="admin-drivers">
<AdminPageHeader title="Taxis" subtitle="Directorio de conductores">
<button class="btn-add" @click="openRegisterModal" aria-label="Agregar nuevo taxi">
<span class="material-icons notranslate" translate="no" aria-hidden="true">add</span>
Nuevo taxi
</button>
</AdminPageHeader>
<!-- Loading -->
<div v-if="isLoading" class="state-center" aria-busy="true" aria-label="Cargando...">
<LoadingBranded message="Cargando conductores..." icon="local_taxi" />
</div>
<!-- Grid -->
<div v-else-if="taxis.length > 0" class="taxis-grid" role="list" aria-label="Lista de taxis">
<article
v-for="taxi in taxis"
:key="taxi.id"
class="taxi-card glass-effect"
role="listitem"
>
<!-- Avatar + name -->
<div class="card-top">
<div class="avatar" aria-hidden="true">
<AppImage v-if="taxi.image_url" :src="taxi.image_url" type="taxi" alt="" />
<span v-else class="material-icons notranslate icon-filled" translate="no">local_taxi</span>
</div>
<div class="card-info">
<h3 class="driver-name">{{ taxi.owner_name }}</h3>
<a :href="`tel:${taxi.phone_number}`" class="phone-link">
<span class="material-icons notranslate" translate="no" aria-hidden="true">call</span>
{{ taxi.phone_number }}
</a>
</div>
<div class="card-actions">
<button class="icon-btn" @click.stop="editTaxi(taxi)" aria-label="Editar">
<span class="material-icons notranslate" translate="no" aria-hidden="true">edit</span>
</button>
<button class="icon-btn icon-btn--danger" @click.stop="deleteTaxi(taxi)" aria-label="Eliminar">
<span class="material-icons notranslate" translate="no" aria-hidden="true">delete</span>
</button>
</div>
</div>
<!-- Tags row -->
<div class="card-tags" aria-label="Detalles del taxi">
<span class="tag tag--plate">{{ taxi.license_plate }}</span>
<span v-if="taxi.corregimiento" class="tag">{{ taxi.corregimiento }}</span>
<span v-if="taxi.vehicle_type" class="tag">{{ taxi.vehicle_type }}</span>
<span v-if="taxi.english_speaking" class="tag tag--blue">EN</span>
<span v-if="taxi.is_accessible" class="tag tag--teal"></span>
</div>
<!-- Meta row -->
<div class="card-meta">
<div class="shift-pills" aria-label="Horarios">
<span
v-for="s in (taxi.shifts?.length ? taxi.shifts : (taxi.shift ? [taxi.shift] : []))"
:key="s"
class="shift-pill"
>{{ getShiftLabel(s) }}</span>
</div>
<div class="meta-right">
<span class="rating" aria-label="`Rating ${taxi.rating || 5}`">
<span class="material-icons notranslate icon-filled" translate="no" aria-hidden="true">star</span>
{{ (taxi.rating || 5).toFixed(1) }}
</span>
<span
class="status-dot"
:class="taxi.is_active ? 'status-dot--active' : 'status-dot--inactive'"
:aria-label="taxi.is_active ? 'Activo' : 'Inactivo'"
>
{{ taxi.is_active ? 'Activo' : 'Inactivo' }}
</span>
</div>
</div>
</article>
</div>
<!-- Empty -->
<div v-else class="state-center" role="status">
<span class="material-icons notranslate empty-icon icon-filled" translate="no" aria-hidden="true">local_taxi</span>
<p>No hay taxis en el directorio</p>
<button class="btn-add" @click="openRegisterModal">
<span class="material-icons notranslate" translate="no" aria-hidden="true">add</span>
Agregar taxi
</button>
</div>
<!-- Modal: Add / Edit -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showModal" class="modal-overlay" role="dialog" aria-modal="true" :aria-label="modalTitle" @click.self="closeModal">
<div class="modal-sheet">
<div class="modal-header">
<h2 class="modal-title">{{ modalTitle }}</h2>
<button class="modal-close" @click="closeModal" aria-label="Cerrar">
<span class="material-icons notranslate" translate="no" aria-hidden="true">close</span>
</button>
</div>
<form @submit.prevent="saveTaxi" class="modal-body">
<div class="form-grid">
<div class="form-field">
<label for="f-name">Conductor *</label>
<input id="f-name" v-model="taxiForm.owner_name" type="text" placeholder="Juan Pérez" required autocomplete="name">
</div>
<div class="form-field">
<label for="f-phone">Teléfono *</label>
<input id="f-phone" v-model="taxiForm.phone_number" type="tel" placeholder="+507 6123-4567" required autocomplete="tel">
</div>
<div class="form-field">
<label for="f-plate">Placa *</label>
<input id="f-plate" v-model="taxiForm.license_plate" type="text" placeholder="CHI-1234" required>
</div>
<div class="form-field">
<label for="f-vehicle">Tipo de vehículo</label>
<input id="f-vehicle" v-model="taxiForm.vehicle_type" type="text" placeholder="Toyota Corolla / Van">
</div>
<div class="form-field">
<label for="f-zone">Zona de servicio *</label>
<select id="f-zone" v-model="taxiForm.corregimiento" required>
<option value="">Seleccionar zona</option>
<option value="Boquete">Boquete</option>
<option value="David - Boquete">David Boquete</option>
<option value="Boquete - David">Boquete David</option>
<option value="Aeropuerto - Boquete">Aeropuerto Boquete</option>
</select>
</div>
<div class="form-field">
<label for="f-coop">Cooperativa</label>
<input id="f-coop" v-model="taxiForm.cooperative" type="text" placeholder="Cooperativa Boquete">
</div>
<div class="form-field">
<label for="f-rating">Rating (15)</label>
<input id="f-rating" v-model.number="taxiForm.rating" type="number" min="1" max="5" step="0.1" placeholder="5.0">
</div>
<div class="form-field form-field--full">
<label>Horarios de servicio</label>
<div class="shift-check-group">
<label v-for="s in ['dia', 'tarde', 'noche', 'aeropuerto']" :key="s" class="shift-check">
<input type="checkbox" v-model="taxiForm.shifts" :value="s">
<span>{{ getShiftLabel(s) }}</span>
</label>
</div>
</div>
<div class="form-field form-field--full">
<label>Opciones</label>
<div class="flag-row">
<label class="flag-item">
<input v-model="taxiForm.english_speaking" type="checkbox">
<span>Habla inglés</span>
</label>
<label class="flag-item">
<input v-model="taxiForm.is_accessible" type="checkbox">
<span>Accesible</span>
</label>
<label class="flag-item">
<input v-model="taxiForm.is_active" type="checkbox">
<span>Activo en directorio</span>
</label>
</div>
</div>
<div class="form-field form-field--full">
<label for="f-photo">Foto del conductor</label>
<input id="f-photo" type="file" @change="handleTaxiFileChange" accept="image/*">
</div>
</div>
<p v-if="taxiError" class="form-error" role="alert">{{ taxiError }}</p>
<div class="modal-footer">
<button type="button" class="btn-cancel" @click="closeModal">Cancelar</button>
<button type="submit" class="btn-save" :disabled="isSaving">
<span v-if="isSaving" class="material-icons notranslate spin" translate="no" aria-hidden="true">sync</span>
{{ isSaving ? 'Guardando' : 'Guardar' }}
</button>
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/supabase'
import AppImage from '@/components/AppImage.vue'
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
import LoadingBranded from '@/components/common/LoadingBranded.vue'
const router = useRouter()
const isLoading = ref(false)
const taxis = ref<any[]>([])
const showModal = ref(false)
const editingTaxi = ref<any>(null)
const isSaving = ref(false)
const taxiError = ref('')
const photoFile = ref<File | null>(null)
const taxiForm = reactive({
owner_name: '',
phone_number: '',
license_plate: '',
vehicle_type: '',
corregimiento: '',
shifts: [] as string[],
cooperative: '',
rating: 5.0,
english_speaking: false,
is_accessible: false,
is_active: true
})
const modalTitle = computed(() => editingTaxi.value ? 'Editar taxi' : 'Nuevo taxi')
onMounted(loadData)
async function loadData() {
isLoading.value = true
try {
const { data, error } = await supabase.from('taxis').select('*').order('owner_name')
if (error) throw error
taxis.value = data || []
} catch (e) {
console.error('Error cargando taxis:', e)
} finally {
isLoading.value = false
}
}
function openRegisterModal() {
showModal.value = true
}
function closeModal() {
showModal.value = false
editingTaxi.value = null
photoFile.value = null
taxiError.value = ''
Object.assign(taxiForm, {
owner_name: '', phone_number: '', license_plate: '', vehicle_type: '',
corregimiento: '', shifts: [], cooperative: '', rating: 5.0,
english_speaking: false, is_accessible: false, is_active: true
})
}
function editTaxi(taxi: any) {
editingTaxi.value = taxi
const shiftsArr: string[] = Array.isArray(taxi.shifts) && taxi.shifts.length
? taxi.shifts
: (taxi.shift ? [taxi.shift] : [])
Object.assign(taxiForm, {
owner_name: taxi.owner_name,
phone_number: taxi.phone_number,
license_plate: taxi.license_plate,
vehicle_type: taxi.vehicle_type || '',
corregimiento: taxi.corregimiento,
shifts: shiftsArr,
cooperative: taxi.cooperative || '',
rating: taxi.rating || 5.0,
english_speaking: taxi.english_speaking || false,
is_accessible: taxi.is_accessible || false,
is_active: taxi.is_active
})
photoFile.value = null
taxiError.value = ''
showModal.value = true
}
function handleTaxiFileChange(event: Event) {
const target = event.target as HTMLInputElement
if (target.files?.[0]) photoFile.value = target.files[0]
}
async function saveTaxi() {
isSaving.value = true
taxiError.value = ''
try {
let image_url = editingTaxi.value?.image_url ?? null
if (photoFile.value) {
const ext = photoFile.value.name.split('.').pop()
const filename = `taxis/${Date.now()}.${ext}`
const { error: upErr } = await supabase.storage.from('uploads').upload(filename, photoFile.value)
if (upErr) throw upErr
const { data: urlData } = supabase.storage.from('uploads').getPublicUrl(filename)
image_url = urlData.publicUrl
}
const payload = { ...taxiForm, shift: taxiForm.shifts[0] || null, image_url }
if (editingTaxi.value) {
const { error } = await supabase.from('taxis').update(payload).eq('id', editingTaxi.value.id)
if (error) throw error
} else {
const { error } = await supabase.from('taxis').insert([payload])
if (error) throw error
}
closeModal()
await loadData()
} catch (e: any) {
taxiError.value = e.message || 'Error al guardar'
} finally {
isSaving.value = false
}
}
async function deleteTaxi(taxi: any) {
if (!confirm(`¿Eliminar a ${taxi.owner_name} del directorio?`)) return
try {
const { error } = await supabase.from('taxis').delete().eq('id', taxi.id)
if (error) throw error
await loadData()
} catch {
alert('Error al eliminar el taxi')
}
}
function getShiftLabel(shift: string) {
const labels: Record<string, string> = {
dia: 'Día', tarde: 'Tarde', noche: 'Noche', aeropuerto: 'Aeropuerto'
}
return labels[shift] || shift
}
</script>
<style scoped>
.admin-drivers {
padding-bottom: 5rem;
}
/* ── Add button ── */
.btn-add {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: var(--active-color);
color: #101820;
border: none;
border-radius: 99px;
font-size: 0.875rem;
font-weight: 800;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
min-height: 40px;
}
.btn-add:focus-visible {
outline: 2px solid var(--active-color);
outline-offset: 3px;
}
@media (hover: hover) {
.btn-add:hover {
box-shadow: 0 4px 14px rgba(254, 231, 21, 0.35);
transform: translateY(-1px);
}
}
.btn-add .material-icons {
font-size: 1.125rem;
}
/* ── States ── */
.state-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 1.5rem;
text-align: center;
color: var(--text-secondary);
}
.empty-icon {
font-size: 3rem;
opacity: 0.3;
}
/* ── Grid ── */
.taxis-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
padding: 0 1rem;
}
@media (min-width: 640px) {
.taxis-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
@media (min-width: 1024px) {
.taxis-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* ── Taxi Card ── */
.taxi-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 1.125rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: border-color 0.18s ease;
}
@media (hover: hover) {
.taxi-card:hover {
border-color: rgba(254, 231, 21, 0.25);
}
}
.card-top {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-secondary);
border: 1.5px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar .material-icons {
font-size: 1.5rem;
color: var(--active-color);
}
.card-info {
flex: 1;
min-width: 0;
}
.driver-name {
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 0.125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.phone-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--text-secondary);
text-decoration: none;
font-weight: 500;
}
.phone-link .material-icons {
font-size: 0.875rem;
}
.phone-link:hover {
color: var(--active-color);
}
.card-actions {
display: flex;
gap: 0.375rem;
flex-shrink: 0;
}
.icon-btn {
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.icon-btn:focus-visible {
outline: 2px solid var(--active-color);
outline-offset: 2px;
}
.icon-btn .material-icons {
font-size: 1rem;
}
@media (hover: hover) {
.icon-btn:hover {
background: var(--hover-bg);
border-color: var(--active-color);
color: var(--text-primary);
}
.icon-btn--danger:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
}
/* ── Tags ── */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tag {
padding: 0.125rem 0.625rem;
border-radius: 99px;
font-size: 0.75rem;
font-weight: 700;
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.tag--plate {
font-family: 'Courier New', monospace;
color: var(--text-primary);
letter-spacing: 0.05em;
}
.tag--blue {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
border-color: rgba(96, 165, 250, 0.3);
}
.tag--teal {
background: rgba(52, 211, 153, 0.15);
color: #34d399;
border-color: rgba(52, 211, 153, 0.3);
}
/* ── Meta row ── */
.card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.shift-pills {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.shift-pill {
background: rgba(254, 231, 21, 0.12);
color: var(--active-color);
border: 1px solid rgba(254, 231, 21, 0.25);
padding: 0.125rem 0.5rem;
border-radius: 99px;
font-size: 0.6875rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.meta-right {
display: flex;
align-items: center;
gap: 0.625rem;
flex-shrink: 0;
}
.rating {
display: flex;
align-items: center;
gap: 0.125rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--active-color);
}
.rating .material-icons {
font-size: 0.875rem;
}
.status-dot {
font-size: 0.75rem;
font-weight: 700;
padding: 0.125rem 0.5rem;
border-radius: 99px;
}
.status-dot--active {
background: rgba(52, 211, 153, 0.12);
color: #34d399;
}
.status-dot--inactive {
background: var(--bg-secondary);
color: var(--text-secondary);
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 200;
padding: 0;
}
@media (min-width: 600px) {
.modal-overlay {
align-items: center;
padding: 1.5rem;
}
}
.modal-sheet {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 1.5rem 1.5rem 0 0;
width: 100%;
max-height: 90dvh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
@media (min-width: 600px) {
.modal-sheet {
border-radius: 1.5rem;
max-width: 560px;
max-height: 85dvh;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
background: var(--bg-primary);
z-index: 10;
}
.modal-title {
font-size: 1.125rem;
font-weight: 800;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.02em;
}
.modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
}
.modal-close:focus-visible {
outline: 2px solid var(--active-color);
outline-offset: 2px;
}
.modal-close .material-icons {
font-size: 1rem;
}
@media (hover: hover) {
.modal-close:hover {
background: var(--hover-bg);
}
}
.modal-body {
padding: 1.25rem;
overflow-y: auto;
}
/* ── Form ── */
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-field--full {
grid-column: 1 / -1;
}
.form-field label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-field input[type="text"],
.form-field input[type="tel"],
.form-field input[type="number"],
.form-field select {
padding: 0.625rem 0.875rem;
border: 1.5px solid var(--border-color);
border-radius: 0.625rem;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.9375rem;
font-family: inherit;
transition: border-color 0.15s ease;
}
.form-field input:focus,
.form-field select:focus {
outline: none;
border-color: var(--active-color);
}
.form-field input[type="file"] {
padding: 0.5rem;
border: 1.5px dashed var(--border-color);
border-radius: 0.625rem;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
}
.shift-check-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg-secondary);
border: 1.5px solid var(--border-color);
border-radius: 0.625rem;
}
.shift-check {
display: flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
padding: 0.25rem 0.5rem;
}
.flag-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.flag-item {
display: flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.form-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
padding: 0.75rem 1rem;
border-radius: 0.625rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.modal-footer {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding-top: 0.5rem;
}
.btn-cancel {
padding: 0.625rem 1.25rem;
background: var(--bg-secondary);
border: 1.5px solid var(--border-color);
border-radius: 99px;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
min-height: 40px;
transition: border-color 0.15s ease;
}
.btn-cancel:hover {
border-color: var(--text-secondary);
}
.btn-save {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.5rem;
background: var(--active-color);
color: #101820;
border: none;
border-radius: 99px;
font-size: 0.875rem;
font-weight: 800;
cursor: pointer;
min-height: 40px;
transition: opacity 0.15s ease;
}
.btn-save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-save .material-icons {
font-size: 1rem;
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Modal transition ── */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active .modal-sheet,
.modal-leave-active .modal-sheet {
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal-sheet,
.modal-leave-to .modal-sheet {
transform: translateY(40px) scale(0.97);
}
@media (prefers-reduced-motion: reduce) {
.modal-enter-active,
.modal-leave-active,
.modal-enter-active .modal-sheet,
.modal-leave-active .modal-sheet {
transition: none;
}
}
@media (max-width: 599px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>