Improved Attendee list look and feel
This commit is contained in:
@@ -1,19 +1,26 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, type ReactNode } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate, Link } from '@tanstack/react-router';
|
import { useParams, useNavigate, Link } from '@tanstack/react-router';
|
||||||
|
import type { AttendeeDto } from '@gruperly/shared';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
Check,
|
Check,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Mail,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
Phone,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
|
ShieldCheck,
|
||||||
|
StickyNote,
|
||||||
Trash2,
|
Trash2,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
@@ -46,6 +53,7 @@ export function GroupDetailView() {
|
|||||||
// Modals & Tabs
|
// Modals & Tabs
|
||||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||||
const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false);
|
const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false);
|
||||||
|
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeDto | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick');
|
const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick');
|
||||||
|
|
||||||
// Quick form state
|
// Quick form state
|
||||||
@@ -100,7 +108,7 @@ export function GroupDetailView() {
|
|||||||
const createAttendeeMutation = useMutation({
|
const createAttendeeMutation = useMutation({
|
||||||
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
|
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
|
||||||
onSuccess: (created) => {
|
onSuccess: (created) => {
|
||||||
toast.success(`Alumno ${created.fullName} agregado con éxito.`);
|
toast.success(`Miembro ${created.fullName} agregado con éxito.`);
|
||||||
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
|
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
|
||||||
setFirstName('');
|
setFirstName('');
|
||||||
setLastName('');
|
setLastName('');
|
||||||
@@ -110,7 +118,7 @@ export function GroupDetailView() {
|
|||||||
setIsAddAttendeeModalOpen(false);
|
setIsAddAttendeeModalOpen(false);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast.error(err.message || 'Error al agregar alumno.');
|
toast.error(err.message || 'Error al agregar miembro.');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +134,7 @@ export function GroupDetailView() {
|
|||||||
setIsAddAttendeeModalOpen(false);
|
setIsAddAttendeeModalOpen(false);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast.error(err.message || 'Error al importar alumnos.');
|
toast.error(err.message || 'Error al importar miembros.');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,7 +220,7 @@ export function GroupDetailView() {
|
|||||||
const handleConfirmBulkImport = () => {
|
const handleConfirmBulkImport = () => {
|
||||||
const validRows = parsedContacts.filter((c) => c.isValid);
|
const validRows = parsedContacts.filter((c) => c.isValid);
|
||||||
if (validRows.length === 0) {
|
if (validRows.length === 0) {
|
||||||
toast.error('No hay alumnos válidos para importar.');
|
toast.error('No hay miembros válidos para importar.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bulkImportMutation.mutate(
|
bulkImportMutation.mutate(
|
||||||
@@ -326,7 +334,7 @@ export function GroupDetailView() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Users className="size-5 text-accent shrink-0" />
|
<Users className="size-5 text-accent shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-foreground/50 uppercase">Alumnos & Cupo</p>
|
<p className="text-xs font-medium text-foreground/50 uppercase">Miembros & Cupo</p>
|
||||||
<p className="font-medium text-primary">
|
<p className="font-medium text-primary">
|
||||||
{attendees.length} inscritos {group.capacity ? `· Cupo de ${group.capacity}` : ''}
|
{attendees.length} inscritos {group.capacity ? `· Cupo de ${group.capacity}` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -380,9 +388,9 @@ export function GroupDetailView() {
|
|||||||
{attendeesQuery.isSuccess && attendees.length === 0 ? (
|
{attendeesQuery.isSuccess && attendees.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-border py-12 px-4 text-center">
|
<div className="rounded-xl border border-dashed border-border py-12 px-4 text-center">
|
||||||
<Users className="size-10 text-foreground/30 mx-auto mb-2" />
|
<Users className="size-10 text-foreground/30 mx-auto mb-2" />
|
||||||
<p className="font-medium text-primary">Todavía no hay alumnos en este grupo</p>
|
<p className="font-medium text-primary">Todavía no hay miembros en este grupo</p>
|
||||||
<p className="text-sm text-foreground/60 mt-1 max-w-sm mx-auto">
|
<p className="text-sm text-foreground/60 mt-1 max-w-sm mx-auto">
|
||||||
Puedes compartir el enlace de invitación único o agregar alumnos de forma manual o masiva.
|
Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex items-center justify-center gap-3">
|
<div className="mt-4 flex items-center justify-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -401,7 +409,7 @@ export function GroupDetailView() {
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Agregar Alumnos
|
Agregar Miembros
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,7 +417,7 @@ export function GroupDetailView() {
|
|||||||
|
|
||||||
{attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? (
|
{attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? (
|
||||||
<div className="py-8 text-center text-sm text-foreground/60">
|
<div className="py-8 text-center text-sm text-foreground/60">
|
||||||
No se encontraron alumnos que coincidan con la búsqueda.
|
No se encontraron miembros que coincidan con la búsqueda.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -418,18 +426,18 @@ export function GroupDetailView() {
|
|||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="border-b border-border text-xs uppercase text-foreground/60 font-semibold">
|
<thead className="border-b border-border text-xs uppercase text-foreground/60 font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="pb-3 px-3">Alumno</th>
|
<th className="pb-3 px-3">Nombre</th>
|
||||||
<th className="pb-3 px-3">WhatsApp / Teléfono</th>
|
<th className="pb-3 px-3">Teléfono</th>
|
||||||
<th className="pb-3 px-3">Email</th>
|
<th className="pb-3 px-3 w-8" />
|
||||||
<th className="pb-3 px-3">Notas</th>
|
|
||||||
<th className="pb-3 px-3 text-right">Incorporado</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{filteredAttendees.map((attendee) => {
|
{filteredAttendees.map((attendee) => (
|
||||||
const cleanPhone = attendee.phone?.replace(/\D/g, '') ?? '';
|
<tr
|
||||||
return (
|
key={attendee.id}
|
||||||
<tr key={attendee.id} className="hover:bg-primary-soft/50 transition-colors">
|
className="hover:bg-primary-soft/50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => setSelectedAttendee(attendee)}
|
||||||
|
>
|
||||||
<td className="py-3 px-3">
|
<td className="py-3 px-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="size-8 rounded-full bg-accent/10 text-accent font-semibold flex items-center justify-center text-xs">
|
<div className="size-8 rounded-full bg-accent/10 text-accent font-semibold flex items-center justify-center text-xs">
|
||||||
@@ -438,34 +446,14 @@ export function GroupDetailView() {
|
|||||||
<span className="font-medium text-primary">{attendee.fullName}</span>
|
<span className="font-medium text-primary">{attendee.fullName}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3">
|
|
||||||
{attendee.phone ? (
|
|
||||||
<a
|
|
||||||
href={`https://wa.me/${cleanPhone}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-foreground hover:text-success transition-colors"
|
|
||||||
title="Abrir chat en WhatsApp"
|
|
||||||
>
|
|
||||||
<MessageCircle className="size-4 text-success" />
|
|
||||||
<span>{attendee.phone}</span>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="text-foreground/40">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3 text-foreground/70">
|
<td className="py-3 px-3 text-foreground/70">
|
||||||
{attendee.email || <span className="text-foreground/40">—</span>}
|
{attendee.phone || <span className="text-foreground/40">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-foreground/70 text-xs">
|
<td className="py-3 px-1 text-foreground/30">
|
||||||
{attendee.notes || <span className="text-foreground/40">—</span>}
|
<ChevronRight className="size-4" />
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3 text-right text-xs text-foreground/50">
|
|
||||||
{new Date(attendee.createdAt).toLocaleDateString('es-ES')}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -477,7 +465,7 @@ export function GroupDetailView() {
|
|||||||
isOpen={isInviteModalOpen}
|
isOpen={isInviteModalOpen}
|
||||||
onClose={() => setIsInviteModalOpen(false)}
|
onClose={() => setIsInviteModalOpen(false)}
|
||||||
title="Compartir Link de Invitación"
|
title="Compartir Link de Invitación"
|
||||||
description="Envía este enlace a tus alumnos para que completen sus datos e ingresen directamente al grupo."
|
description="Envía este enlace a tus miembros para que completen sus datos e ingresen directamente al grupo."
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -675,7 +663,7 @@ export function GroupDetailView() {
|
|||||||
) : (
|
) : (
|
||||||
<Check className="size-4" />
|
<Check className="size-4" />
|
||||||
)}
|
)}
|
||||||
<span>Guardar Alumno</span>
|
<span>Guardar Miembro</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -847,6 +835,138 @@ export function GroupDetailView() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* MODAL: DETALLE DEL PARTICIPANTE */}
|
||||||
|
<Modal
|
||||||
|
isOpen={selectedAttendee !== null}
|
||||||
|
onClose={() => setSelectedAttendee(null)}
|
||||||
|
title="Detalle del Participante"
|
||||||
|
maxWidth="sm"
|
||||||
|
>
|
||||||
|
{selectedAttendee ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Header: avatar + name */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-12 rounded-full bg-accent/10 text-accent font-bold flex items-center justify-center text-lg">
|
||||||
|
{selectedAttendee.fullName.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-lg font-semibold text-primary truncate">
|
||||||
|
{selectedAttendee.fullName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-foreground/50">
|
||||||
|
Incorporado el{' '}
|
||||||
|
{new Date(selectedAttendee.createdAt).toLocaleDateString('es-ES', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info rows */}
|
||||||
|
<div className="space-y-1 rounded-xl border border-border bg-primary-soft/30 divide-y divide-border">
|
||||||
|
<DetailRow
|
||||||
|
icon={<Phone className="size-4 text-accent" />}
|
||||||
|
label="Teléfono"
|
||||||
|
>
|
||||||
|
{selectedAttendee.phone ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-primary">{selectedAttendee.phone}</span>
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/${selectedAttendee.phone.replace(/\D/g, '')}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-success hover:text-success/80 transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MessageCircle className="size-3.5" />
|
||||||
|
<span>WhatsApp</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-foreground/40">Sin teléfono</span>
|
||||||
|
)}
|
||||||
|
</DetailRow>
|
||||||
|
|
||||||
|
<DetailRow
|
||||||
|
icon={<Mail className="size-4 text-accent" />}
|
||||||
|
label="Email"
|
||||||
|
>
|
||||||
|
{selectedAttendee.email ? (
|
||||||
|
<a
|
||||||
|
href={`mailto:${selectedAttendee.email}`}
|
||||||
|
className="text-primary hover:text-accent transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{selectedAttendee.email}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-foreground/40">Sin email</span>
|
||||||
|
)}
|
||||||
|
</DetailRow>
|
||||||
|
|
||||||
|
{(selectedAttendee.guardianName || selectedAttendee.guardianPhone) ? (
|
||||||
|
<>
|
||||||
|
<DetailRow
|
||||||
|
icon={<ShieldCheck className="size-4 text-accent" />}
|
||||||
|
label="Responsable"
|
||||||
|
>
|
||||||
|
<span className="text-primary">
|
||||||
|
{selectedAttendee.guardianName || <span className="text-foreground/40">—</span>}
|
||||||
|
</span>
|
||||||
|
</DetailRow>
|
||||||
|
{selectedAttendee.guardianPhone ? (
|
||||||
|
<DetailRow
|
||||||
|
icon={<Phone className="size-4 text-accent" />}
|
||||||
|
label="Tel. Responsable"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-primary">{selectedAttendee.guardianPhone}</span>
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/${selectedAttendee.guardianPhone.replace(/\D/g, '')}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-success hover:text-success/80 transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MessageCircle className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</DetailRow>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DetailRow
|
||||||
|
icon={<StickyNote className="size-4 text-accent" />}
|
||||||
|
label="Notas"
|
||||||
|
>
|
||||||
|
{selectedAttendee.notes ? (
|
||||||
|
<span className="text-primary text-xs leading-relaxed">{selectedAttendee.notes}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-foreground/40">Sin notas</span>
|
||||||
|
)}
|
||||||
|
</DetailRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DetailRow({ icon, label, children }: { icon: ReactNode; label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 px-4 py-3">
|
||||||
|
<div className="mt-0.5 shrink-0">{icon}</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[11px] font-medium text-foreground/50 uppercase tracking-wide mb-0.5">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<div className="text-sm">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user