2025-08-28 10:41:04 +02:00
|
|
|
import React, { useState } from 'react';
|
|
|
|
|
import { Users, Plus, Search, Mail, Phone, Shield, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
|
|
|
|
|
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
|
|
|
|
import { PageHeader } from '../../../../components/layout';
|
2025-09-11 18:21:32 +02:00
|
|
|
import { useTeamMembers } from '../../../../api/hooks/tenant';
|
|
|
|
|
import { useAllUsers, useUpdateUser } from '../../../../api/hooks/user';
|
|
|
|
|
import { useAuthUser } from '../../../../stores/auth.store';
|
|
|
|
|
import { useToast } from '../../../../hooks/ui/useToast';
|
2025-08-28 10:41:04 +02:00
|
|
|
|
|
|
|
|
const TeamPage: React.FC = () => {
|
2025-09-11 18:21:32 +02:00
|
|
|
const { addToast } = useToast();
|
|
|
|
|
const user = useAuthUser();
|
|
|
|
|
const tenantId = user?.tenant_id || '';
|
|
|
|
|
|
|
|
|
|
const { data: teamMembers = [], isLoading, error } = useTeamMembers(tenantId, true, { enabled: !!tenantId });
|
|
|
|
|
const { data: allUsers = [] } = useAllUsers();
|
|
|
|
|
const updateUserMutation = useUpdateUser();
|
|
|
|
|
|
2025-08-28 10:41:04 +02:00
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
|
const [selectedRole, setSelectedRole] = useState('all');
|
|
|
|
|
const [showForm, setShowForm] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const roles = [
|
|
|
|
|
{ value: 'all', label: 'Todos los Roles', count: teamMembers.length },
|
2025-09-11 18:21:32 +02:00
|
|
|
{ value: 'owner', label: 'Propietario', count: teamMembers.filter(m => m.role === 'owner').length },
|
|
|
|
|
{ value: 'admin', label: 'Administrador', count: teamMembers.filter(m => m.role === 'admin').length },
|
2025-08-28 10:41:04 +02:00
|
|
|
{ value: 'manager', label: 'Gerente', count: teamMembers.filter(m => m.role === 'manager').length },
|
2025-09-11 18:21:32 +02:00
|
|
|
{ value: 'employee', label: 'Empleado', count: teamMembers.filter(m => m.role === 'employee').length }
|
2025-08-28 10:41:04 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const teamStats = {
|
|
|
|
|
total: teamMembers.length,
|
|
|
|
|
active: teamMembers.filter(m => m.status === 'active').length,
|
|
|
|
|
departments: {
|
|
|
|
|
production: teamMembers.filter(m => m.department === 'Producción').length,
|
|
|
|
|
sales: teamMembers.filter(m => m.department === 'Ventas').length,
|
|
|
|
|
admin: teamMembers.filter(m => m.department === 'Administración').length
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getRoleBadgeColor = (role: string) => {
|
|
|
|
|
switch (role) {
|
|
|
|
|
case 'manager': return 'purple';
|
|
|
|
|
case 'baker': return 'green';
|
|
|
|
|
case 'cashier': return 'blue';
|
|
|
|
|
case 'assistant': return 'yellow';
|
|
|
|
|
default: return 'gray';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getStatusColor = (status: string) => {
|
|
|
|
|
return status === 'active' ? 'green' : 'red';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getRoleLabel = (role: string) => {
|
|
|
|
|
switch (role) {
|
|
|
|
|
case 'manager': return 'Gerente';
|
|
|
|
|
case 'baker': return 'Panadero';
|
|
|
|
|
case 'cashier': return 'Cajero';
|
|
|
|
|
case 'assistant': return 'Asistente';
|
|
|
|
|
default: return role;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const filteredMembers = teamMembers.filter(member => {
|
|
|
|
|
const matchesRole = selectedRole === 'all' || member.role === selectedRole;
|
2025-09-11 18:21:32 +02:00
|
|
|
const matchesSearch = member.user.first_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
|
|
member.user.last_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
|
|
member.user.email.toLowerCase().includes(searchTerm.toLowerCase());
|
2025-08-28 10:41:04 +02:00
|
|
|
return matchesRole && matchesSearch;
|
|
|
|
|
});
|
2025-09-11 18:21:32 +02:00
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-6 space-y-6">
|
|
|
|
|
<PageHeader
|
|
|
|
|
title="Gestión de Equipo"
|
|
|
|
|
description="Administra los miembros del equipo, roles y permisos"
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
|
|
|
<p>Cargando miembros del equipo...</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-08-28 10:41:04 +02:00
|
|
|
|
|
|
|
|
const formatLastLogin = (timestamp: string) => {
|
|
|
|
|
const date = new Date(timestamp);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
|
|
|
|
|
|
if (diffInDays === 0) {
|
|
|
|
|
return 'Hoy ' + date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
} else if (diffInDays === 1) {
|
|
|
|
|
return 'Ayer';
|
|
|
|
|
} else {
|
|
|
|
|
return `hace ${diffInDays} días`;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-6 space-y-6">
|
|
|
|
|
<PageHeader
|
|
|
|
|
title="Gestión de Equipo"
|
|
|
|
|
description="Administra los miembros del equipo, roles y permisos"
|
|
|
|
|
action={
|
|
|
|
|
<Button onClick={() => setShowForm(true)}>
|
|
|
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
|
|
|
Nuevo Miembro
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Team Stats */}
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
|
|
|
<Card className="p-6">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Equipo</p>
|
|
|
|
|
<p className="text-3xl font-bold text-[var(--text-primary)]">{teamStats.total}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
|
|
|
|
<Users className="h-6 w-6 text-[var(--color-info)]" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card className="p-6">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Activos</p>
|
|
|
|
|
<p className="text-3xl font-bold text-[var(--color-success)]">{teamStats.active}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
|
|
|
|
<UserCheck className="h-6 w-6 text-[var(--color-success)]" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card className="p-6">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Producción</p>
|
|
|
|
|
<p className="text-3xl font-bold text-[var(--color-primary)]">{teamStats.departments.production}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
|
|
|
|
<Users className="h-6 w-6 text-[var(--color-primary)]" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card className="p-6">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Ventas</p>
|
|
|
|
|
<p className="text-3xl font-bold text-purple-600">{teamStats.departments.sales}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
|
|
|
|
<Users className="h-6 w-6 text-purple-600" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Filters and Search */}
|
|
|
|
|
<Card className="p-6">
|
|
|
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Buscar miembros del equipo..."
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
className="pl-10"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2 flex-wrap">
|
|
|
|
|
{roles.map((role) => (
|
|
|
|
|
<button
|
|
|
|
|
key={role.value}
|
|
|
|
|
onClick={() => setSelectedRole(role.value)}
|
|
|
|
|
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
|
|
|
|
selectedRole === role.value
|
|
|
|
|
? 'bg-blue-600 text-white'
|
|
|
|
|
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-quaternary)]'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{role.label} ({role.count})
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Team Members List */}
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
|
{filteredMembers.map((member) => (
|
|
|
|
|
<Card key={member.id} className="p-6">
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div className="flex items-start space-x-4 flex-1">
|
|
|
|
|
<div className="w-12 h-12 bg-[var(--bg-quaternary)] rounded-full flex items-center justify-center">
|
|
|
|
|
<Users className="w-6 h-6 text-[var(--text-tertiary)]" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="flex items-center space-x-3 mb-2">
|
2025-09-11 18:21:32 +02:00
|
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{member.user.first_name} {member.user.last_name}</h3>
|
2025-08-28 10:41:04 +02:00
|
|
|
<Badge variant={getStatusColor(member.status)}>
|
|
|
|
|
{member.status === 'active' ? 'Activo' : 'Inactivo'}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1 mb-3">
|
|
|
|
|
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
|
|
|
|
<Mail className="w-4 h-4 mr-2" />
|
2025-09-11 18:21:32 +02:00
|
|
|
{member.user.email}
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
|
|
|
|
<Phone className="w-4 h-4 mr-2" />
|
2025-09-11 18:21:32 +02:00
|
|
|
{member.user.phone || 'No disponible'}
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center space-x-2 mb-3">
|
|
|
|
|
<Badge variant={getRoleBadgeColor(member.role)}>
|
|
|
|
|
{getRoleLabel(member.role)}
|
|
|
|
|
</Badge>
|
|
|
|
|
<Badge variant="gray">
|
|
|
|
|
{member.department}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="text-sm text-[var(--text-tertiary)] mb-3">
|
2025-09-11 18:21:32 +02:00
|
|
|
<p>Se unió: {new Date(member.joined_at).toLocaleDateString('es-ES')}</p>
|
|
|
|
|
<p>Estado: {member.is_active ? 'Activo' : 'Inactivo'}</p>
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Permissions */}
|
|
|
|
|
<div className="mb-3">
|
|
|
|
|
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">Permisos:</p>
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
2025-09-11 18:21:32 +02:00
|
|
|
<span className="px-2 py-1 bg-[var(--color-info)]/10 text-[var(--color-info)] text-xs rounded-full">
|
|
|
|
|
{getRoleLabel(member.role)}
|
|
|
|
|
</span>
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-09-11 18:21:32 +02:00
|
|
|
{/* Member Info */}
|
2025-08-28 10:41:04 +02:00
|
|
|
<div className="text-xs text-[var(--text-tertiary)]">
|
2025-09-11 18:21:32 +02:00
|
|
|
<p className="font-medium mb-1">Información adicional:</p>
|
|
|
|
|
<p>ID: {member.id}</p>
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex space-x-2">
|
|
|
|
|
<Button size="sm" variant="outline">
|
|
|
|
|
<Edit className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className={member.status === 'active' ? 'text-[var(--color-error)] hover:text-[var(--color-error)]' : 'text-[var(--color-success)] hover:text-[var(--color-success)]'}
|
|
|
|
|
>
|
|
|
|
|
{member.status === 'active' ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{filteredMembers.length === 0 && (
|
|
|
|
|
<Card className="p-12 text-center">
|
|
|
|
|
<Users className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
|
|
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se encontraron miembros</h3>
|
|
|
|
|
<p className="text-[var(--text-secondary)]">
|
|
|
|
|
No hay miembros del equipo que coincidan con los filtros seleccionados.
|
|
|
|
|
</p>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Add Member Modal Placeholder */}
|
|
|
|
|
{showForm && (
|
|
|
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
|
|
|
<Card className="p-6 max-w-md w-full mx-4">
|
|
|
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Nuevo Miembro del Equipo</h3>
|
|
|
|
|
<p className="text-[var(--text-secondary)] mb-4">
|
|
|
|
|
Formulario para agregar un nuevo miembro del equipo.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex space-x-2">
|
|
|
|
|
<Button size="sm" onClick={() => setShowForm(false)}>
|
|
|
|
|
Guardar
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" variant="outline" onClick={() => setShowForm(false)}>
|
|
|
|
|
Cancelar
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default TeamPage;
|