Improve the frontend modals

This commit is contained in:
Urtzi Alfaro
2025-10-27 16:33:26 +01:00
parent 61376b7a9f
commit 858d985c92
143 changed files with 9289 additions and 2306 deletions

View File

@@ -23,10 +23,9 @@ import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { useAuthUser, useAuthStore, useAuthActions } from '../../../../stores/auth.store';
import { useAuthUser, useAuthActions } from '../../../../stores/auth.store';
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
import { useCurrentTenant } from '../../../../stores';
import { subscriptionService } from '../../../../api';
// Import the communication preferences component
import CommunicationPreferences, { type NotificationPreferences } from './CommunicationPreferences';
@@ -52,7 +51,6 @@ const NewProfileSettingsPage: React.FC = () => {
const navigate = useNavigate();
const { addToast } = useToast();
const user = useAuthUser();
const token = useAuthStore((state) => state.token);
const { logout } = useAuthActions();
const currentTenant = useCurrentTenant();
@@ -72,7 +70,6 @@ const NewProfileSettingsPage: React.FC = () => {
const [deletePassword, setDeletePassword] = useState('');
const [deleteReason, setDeleteReason] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [subscriptionStatus, setSubscriptionStatus] = useState<any>(null);
const [profileData, setProfileData] = useState<ProfileFormData>({
first_name: '',
@@ -106,22 +103,8 @@ const NewProfileSettingsPage: React.FC = () => {
}
}, [profile]);
// Load subscription status
React.useEffect(() => {
const loadSubscriptionStatus = async () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (tenantId) {
try {
const status = await subscriptionService.getSubscriptionStatus(tenantId);
setSubscriptionStatus(status);
} catch (error) {
console.error('Failed to load subscription status:', error);
}
}
};
loadSubscriptionStatus();
}, [currentTenant, user]);
// Subscription status is not needed on the profile page
// It's already shown in the subscription tab of the main ProfilePage
const languageOptions = [
{ value: 'es', label: 'Español' },
@@ -249,17 +232,11 @@ const NewProfileSettingsPage: React.FC = () => {
const handleDataExport = async () => {
setIsExporting(true);
try {
const response = await fetch('/api/v1/users/me/export', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const { authService } = await import('../../../../api');
const exportData = await authService.exportMyData();
if (!response.ok) {
throw new Error('Failed to export data');
}
const blob = await response.blob();
// Convert to blob and download
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -290,23 +267,8 @@ const NewProfileSettingsPage: React.FC = () => {
setIsDeleting(true);
try {
const response = await fetch('/api/v1/users/me/delete/request', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
confirm_email: deleteConfirmEmail,
password: deletePassword,
reason: deleteReason
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to delete account');
}
const { authService } = await import('../../../../api');
await authService.deleteAccount(deleteConfirmEmail, deletePassword, deleteReason);
addToast(t('common.success'), { type: 'success' });
@@ -717,22 +679,6 @@ const NewProfileSettingsPage: React.FC = () => {
</div>
<div className="space-y-4">
{subscriptionStatus && subscriptionStatus.status === 'active' && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold text-yellow-900 dark:text-yellow-100 mb-1">
Suscripción Activa Detectada
</p>
<p className="text-yellow-800 dark:text-yellow-200">
Tienes una suscripción activa que se cancelará
</p>
</div>
</div>
</div>
)}
<Input
label="Confirma tu email"
type="email"

View File

@@ -1,14 +1,16 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-react';
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck, Eye, Activity } from 'lucide-react';
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig, EmptyState, EditViewModal } from '../../../../components/ui';
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
import { PageHeader } from '../../../../components/layout';
import { useTeamMembers, useAddTeamMember, useAddTeamMemberWithUserCreation, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
import { useUserActivity } from '../../../../api/hooks/user';
import { userService } from '../../../../api/services/user';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
import { TENANT_ROLES } from '../../../../types/roles';
import { TENANT_ROLES, type TenantRole } from '../../../../types/roles';
import { subscriptionService } from '../../../../api/services/subscription';
const TeamPage: React.FC = () => {
@@ -38,7 +40,18 @@ const TeamPage: React.FC = () => {
const [selectedRole, setSelectedRole] = useState('all');
const [showAddForm, setShowAddForm] = useState(false);
const [selectedUserToAdd, setSelectedUserToAdd] = useState('');
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState<string>(TENANT_ROLES.MEMBER);
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState<TenantRole>(TENANT_ROLES.MEMBER);
// Modal state for team member details
const [selectedMember, setSelectedMember] = useState<any>(null);
const [showMemberModal, setShowMemberModal] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [memberFormData, setMemberFormData] = useState<any>({});
// Modal state for activity view
const [showActivityModal, setShowActivityModal] = useState(false);
const [selectedMemberActivity, setSelectedMemberActivity] = useState<any>(null);
const [activityLoading, setActivityLoading] = useState(false);
// Enhanced team members that includes owner information
@@ -96,6 +109,21 @@ const TeamPage: React.FC = () => {
owners: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.OWNER).length,
admins: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.ADMIN).length,
members: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.MEMBER).length,
uniqueRoles: new Set(enhancedTeamMembers.map(m => m.role)).size,
averageDaysInTeam: (() => {
// Only calculate for non-owner members to avoid skewing the average
// Owners are added as placeholders with tenant creation date which skews the average
const nonOwnerMembers = enhancedTeamMembers.filter(m => m.role !== TENANT_ROLES.OWNER);
if (nonOwnerMembers.length === 0) return 0;
const totalDays = nonOwnerMembers.reduce((sum, m) => {
const joinedDate = m.joined_at ? new Date(m.joined_at) : new Date();
const days = Math.floor((Date.now() - joinedDate.getTime()) / (1000 * 60 * 24));
return sum + days;
}, 0);
return Math.round(totalDays / nonOwnerMembers.length);
})()
};
@@ -151,21 +179,24 @@ const TeamPage: React.FC = () => {
};
};
const getMemberActions = (member: any) => {
const getMemberActions = (member: any) => {
const actions = [];
// Primary action - View details (always available)
// This will be implemented in the future to show detailed member info modal
// For now, we can comment it out as there's no modal yet
// actions.push({
// label: 'Ver Detalles',
// icon: Eye,
// priority: 'primary' as const,
// onClick: () => {
// // TODO: Implement member details modal
// console.log('View member details:', member.user_id);
// },
// });
// Primary action - View profile details
actions.push({
label: 'Ver Perfil',
icon: Eye,
onClick: () => handleViewMemberDetails(member),
priority: 'primary' as const
});
// Secondary action - View activity
actions.push({
label: 'Ver Actividad',
icon: Activity,
onClick: () => handleViewActivity(member),
priority: 'secondary' as const
});
// Contextual role change actions (only for non-owners and if user can manage team)
if (canManageTeam && member.role !== TENANT_ROLES.OWNER) {
@@ -204,7 +235,7 @@ const TeamPage: React.FC = () => {
// Remove member action (only for owners and non-owner members)
if (isOwner && member.role !== TENANT_ROLES.OWNER) {
actions.push({
label: 'Remover',
label: 'Remover Miembro',
icon: Trash2,
onClick: () => {
if (confirm('¿Estás seguro de que deseas remover este miembro?')) {
@@ -217,6 +248,72 @@ const TeamPage: React.FC = () => {
}
return actions;
};
const handleViewMemberDetails = (member: any) => {
setSelectedMember(member);
setMemberFormData({
full_name: member.user?.full_name || member.user_full_name || '',
email: member.user?.email || member.user_email || '',
phone: member.user?.phone || '',
role: member.role,
language: member.user?.language || 'es',
timezone: member.user?.timezone || 'Europe/Madrid',
is_active: member.is_active,
joined_at: member.joined_at
});
setModalMode('view');
setShowMemberModal(true);
};
const handleEditMember = () => {
setModalMode('edit');
};
const handleSaveMember = async () => {
// TODO: Implement member update logic
console.log('Saving member:', memberFormData);
setShowMemberModal(false);
};
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
const fieldMap: Record<number, string> = {
0: 'full_name',
1: 'email',
2: 'phone',
3: 'role',
4: 'language',
5: 'timezone',
6: 'is_active'
};
const fieldName = fieldMap[fieldIndex];
if (fieldName) {
// Convert string boolean values back to actual booleans for 'is_active' field
const processedValue = fieldName === 'is_active' ? value === 'true' : value;
setMemberFormData((prev: any) => ({
...prev,
[fieldName]: processedValue
}));
}
};
const handleViewActivity = async (member: any) => {
const userId = member.user_id;
if (!userId) return;
try {
setActivityLoading(true);
const activityData = await userService.getUserActivity(userId);
setSelectedMemberActivity(activityData);
setShowActivityModal(true);
} catch (error) {
console.error('Error fetching user activity:', error);
addToast('Error al cargar la actividad del usuario', { type: 'error' });
} finally {
setActivityLoading(false);
}
};
const filteredMembers = enhancedTeamMembers.filter(member => {
@@ -311,6 +408,7 @@ const TeamPage: React.FC = () => {
canManageTeam ? [{
id: 'add-member',
label: 'Agregar Miembro',
variant: "primary" as const,
icon: Plus,
onClick: () => setShowAddForm(true)
}] : undefined
@@ -343,9 +441,21 @@ const TeamPage: React.FC = () => {
value: teamStats.owners,
icon: Crown,
variant: "purple"
},
{
title: "Roles Únicos",
value: teamStats.uniqueRoles,
icon: Users,
variant: "info"
},
{
title: "Días Promedio",
value: teamStats.averageDaysInTeam,
icon: UserCheck,
variant: "info"
}
]}
columns={4}
columns={3}
gap="md"
/>
@@ -414,29 +524,18 @@ const TeamPage: React.FC = () => {
</div>
{filteredMembers.length === 0 && (
<div className="text-center py-12">
<Users className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron miembros
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{searchTerm || selectedRole !== 'all'
<EmptyState
icon={Users}
title="No se encontraron miembros"
description={
searchTerm || selectedRole !== 'all'
? "No hay miembros que coincidan con los filtros seleccionados"
: "Este tenant aún no tiene miembros del equipo"
}
</p>
{canManageTeam && (
<Button
onClick={() => setShowAddForm(true)}
variant="primary"
size="md"
className="font-medium px-6 py-3 shadow-sm hover:shadow-md transition-all duration-200"
>
<Plus className="w-4 h-4 mr-2 flex-shrink-0" />
<span>Agregar Primer Miembro</span>
</Button>
)}
</div>
}
actionLabel={canManageTeam ? "Agregar Primer Miembro" : undefined}
actionIcon={canManageTeam ? Plus : undefined}
onAction={canManageTeam ? () => setShowAddForm(true) : undefined}
/>
)}
{/* Add Member Modal - Using StatusModal */}
@@ -452,7 +551,7 @@ const TeamPage: React.FC = () => {
try {
// Check subscription limits before adding member
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'users', 1);
const usageCheck = await subscriptionService.checkQuotaLimit(tenantId, 'users', 1);
if (!usageCheck.allowed) {
const errorMessage = usageCheck.message ||
@@ -461,6 +560,10 @@ const TeamPage: React.FC = () => {
throw new Error(errorMessage);
}
// The AddTeamMemberModal returns a string role, but it's always one of the tenant roles
// Since the modal only allows MEMBER, ADMIN, VIEWER (no OWNER), we can safely cast it
const role = userData.role as typeof TENANT_ROLES.MEMBER | typeof TENANT_ROLES.ADMIN | typeof TENANT_ROLES.VIEWER;
// Use appropriate mutation based on whether we're creating a user
if (userData.createUser) {
await addMemberWithUserMutation.mutateAsync({
@@ -471,7 +574,7 @@ const TeamPage: React.FC = () => {
full_name: userData.fullName!,
password: userData.password!,
phone: userData.phone,
role: userData.role,
role,
language: 'es',
timezone: 'Europe/Madrid'
}
@@ -481,7 +584,7 @@ const TeamPage: React.FC = () => {
await addMemberMutation.mutateAsync({
tenantId,
userId: userData.userId!,
role: userData.role,
role,
});
addToast('Miembro agregado exitosamente', { type: 'success' });
}
@@ -503,8 +606,197 @@ const TeamPage: React.FC = () => {
}}
availableUsers={[]}
/>
{/* Team Member Details Modal */}
<EditViewModal
isOpen={showMemberModal}
onClose={() => setShowMemberModal(false)}
mode={modalMode}
onModeChange={setModalMode}
title={memberFormData.full_name || 'Miembro del Equipo'}
subtitle={memberFormData.email}
statusIndicator={selectedMember ? getMemberStatusConfig(selectedMember) : undefined}
sections={[
{
title: 'Información Personal',
icon: Users,
fields: [
{
label: 'Nombre Completo',
value: memberFormData.full_name,
type: 'text',
editable: true,
required: true,
placeholder: 'Introduce el nombre completo',
span: 2
},
{
label: 'Email',
value: memberFormData.email,
type: 'email',
editable: true,
required: true,
placeholder: 'Introduce el email',
span: 2
},
{
label: 'Teléfono',
value: memberFormData.phone,
type: 'tel',
editable: true,
placeholder: 'Introduce el teléfono',
span: 2
}
]
},
{
title: 'Configuración de Cuenta',
icon: Shield,
fields: [
{
label: 'Rol',
value: getRoleLabel(memberFormData.role),
type: 'select',
editable: modalMode === 'edit',
options: [
{ label: 'Miembro - Acceso estándar', value: TENANT_ROLES.MEMBER },
{ label: 'Administrador - Gestión de equipo', value: TENANT_ROLES.ADMIN },
{ label: 'Observador - Solo lectura', value: TENANT_ROLES.VIEWER }
],
span: 2
},
{
label: 'Idioma',
value: memberFormData.language?.toUpperCase() || 'ES',
type: 'select',
editable: modalMode === 'edit',
options: [
{ label: 'Español', value: 'es' },
{ label: 'English', value: 'en' },
{ label: 'Euskera', value: 'eu' }
],
span: 2
},
{
label: 'Zona Horaria',
value: memberFormData.timezone || 'Europe/Madrid',
type: 'select',
editable: modalMode === 'edit',
options: [
{ label: 'Madrid (CET)', value: 'Europe/Madrid' },
{ label: 'London (GMT)', value: 'Europe/London' },
{ label: 'New York (EST)', value: 'America/New_York' }
],
span: 2
},
{
label: 'Estado',
value: memberFormData.is_active ? 'Activo' : 'Inactivo',
type: 'select',
editable: modalMode === 'edit',
options: [
{ label: 'Activo', value: 'true' },
{ label: 'Inactivo', value: 'false' }
],
span: 2
}
]
},
{
title: 'Detalles del Equipo',
icon: UserCheck,
fields: [
{
label: 'Fecha de Ingreso',
value: memberFormData.joined_at,
type: 'date',
editable: false,
span: 2
},
{
label: 'Días en el Equipo',
value: selectedMember ? Math.floor((Date.now() - new Date(selectedMember.joined_at).getTime()) / (1000 * 60 * 60 * 24)) : 0,
type: 'number',
editable: false,
span: 2
},
{
label: 'ID de Usuario',
value: selectedMember?.user_id || 'N/A',
type: 'text',
editable: false,
span: 2
}
]
}
]}
onFieldChange={handleFieldChange}
onEdit={handleEditMember}
onSave={handleSaveMember}
size="lg"
/>
{/* Activity Modal */}
<EditViewModal
isOpen={showActivityModal}
onClose={() => setShowActivityModal(false)}
mode="view"
title="Actividad del Usuario"
subtitle={selectedMemberActivity?.user_id ? `ID: ${selectedMemberActivity.user_id}` : ''}
sections={[
{
title: 'Información Básica',
icon: Activity,
fields: [
{
label: 'Estado de Cuenta',
value: selectedMemberActivity?.is_active ? 'Activa' : 'Inactiva',
type: 'text',
span: 2
},
{
label: 'Estado de Verificación',
value: selectedMemberActivity?.is_verified ? 'Verificada' : 'No verificada',
type: 'text',
span: 2
},
{
label: 'Fecha de Creación',
value: selectedMemberActivity?.account_created ? new Date(selectedMemberActivity.account_created).toLocaleDateString('es-ES') : 'N/A',
type: 'text',
span: 2
}
]
},
{
title: 'Actividad Reciente',
icon: Activity,
fields: [
{
label: 'Último Inicio de Sesión',
value: selectedMemberActivity?.last_login ? new Date(selectedMemberActivity.last_login).toLocaleString('es-ES') : 'Nunca',
type: 'text',
span: 2
},
{
label: 'Última Actividad',
value: selectedMemberActivity?.last_activity ? new Date(selectedMemberActivity.last_activity).toLocaleString('es-ES') : 'N/A',
type: 'text',
span: 2
},
{
label: 'Sesiones Activas',
value: selectedMemberActivity?.active_sessions || 0,
type: 'number',
span: 2
}
]
}
]}
size="lg"
/>
</div>
);
);
};
export default TeamPage;
export default TeamPage;