From 3a461cb184d9f37fb804a315a74609ca4a9ca5b9 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Tue, 20 Jan 2026 04:32:20 -0600 Subject: [PATCH] [TASK-2026-01-20-005] feat: Resolve P2 gaps - Actions, Settings, Kanban EPIC-P2-001: Frontend Actions Implementation - Replace 31 console.log placeholders with navigate() calls - 16 pages updated with proper routing EPIC-P2-002: Settings Subpages Creation - Add CompanySettingsPage.tsx - Add ProfileSettingsPage.tsx - Add SecuritySettingsPage.tsx - Add SystemSettingsPage.tsx - Update router with new routes EPIC-P2-003: Bug Fix ValuationReportsPage - Fix recursive getToday() function EPIC-P2-006: CRM Pipeline Kanban - Add PipelineKanbanPage.tsx - Add KanbanColumn.tsx component - Add KanbanCard.tsx component - Add CRM routes to router Co-Authored-By: Claude Opus 4.5 --- src/app/router/routes.tsx | 104 ++- src/pages/crm/LeadsPage.tsx | 4 +- src/pages/crm/OpportunitiesPage.tsx | 4 +- src/pages/crm/PipelineKanbanPage.tsx | 430 +++++++++ src/pages/financial/AccountsPage.tsx | 8 +- src/pages/financial/InvoicesPage.tsx | 7 +- src/pages/financial/JournalEntriesPage.tsx | 4 +- src/pages/inventory/InventoryCountsPage.tsx | 4 +- src/pages/inventory/MovementsPage.tsx | 4 +- src/pages/inventory/ReorderAlertsPage.tsx | 8 +- src/pages/inventory/ValuationReportsPage.tsx | 5 +- src/pages/projects/ProjectsPage.tsx | 4 +- src/pages/projects/TasksPage.tsx | 4 +- src/pages/purchases/PurchaseOrdersPage.tsx | 10 +- src/pages/purchases/PurchaseReceiptsPage.tsx | 4 +- src/pages/reports/ReportsPage.tsx | 15 +- src/pages/sales/QuotationsPage.tsx | 4 +- src/pages/sales/SalesOrdersPage.tsx | 4 +- src/pages/settings/CompanySettingsPage.tsx | 472 ++++++++++ src/pages/settings/ProfileSettingsPage.tsx | 549 +++++++++++ src/pages/settings/SecuritySettingsPage.tsx | 662 +++++++++++++ src/pages/settings/SystemSettingsPage.tsx | 923 +++++++++++++++++++ src/pages/settings/UsersSettingsPage.tsx | 7 +- src/shared/components/crm/KanbanCard.tsx | 118 +++ src/shared/components/crm/KanbanColumn.tsx | 154 ++++ src/shared/components/crm/index.ts | 3 + 26 files changed, 3483 insertions(+), 32 deletions(-) create mode 100644 src/pages/crm/PipelineKanbanPage.tsx create mode 100644 src/pages/settings/CompanySettingsPage.tsx create mode 100644 src/pages/settings/ProfileSettingsPage.tsx create mode 100644 src/pages/settings/SecuritySettingsPage.tsx create mode 100644 src/pages/settings/SystemSettingsPage.tsx create mode 100644 src/shared/components/crm/KanbanCard.tsx create mode 100644 src/shared/components/crm/KanbanColumn.tsx create mode 100644 src/shared/components/crm/index.ts diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 86a4dbe..2260d2c 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -35,6 +35,20 @@ const PlansPage = lazy(() => import('@pages/billing/PlansPage').then(m => ({ def const InvoicesPage = lazy(() => import('@pages/billing/InvoicesPage').then(m => ({ default: m.InvoicesPage }))); const UsagePage = lazy(() => import('@pages/billing/UsagePage').then(m => ({ default: m.UsagePage }))); +// Settings pages +const SettingsPage = lazy(() => import('@pages/settings/SettingsPage').then(m => ({ default: m.SettingsPage }))); +const CompanySettingsPage = lazy(() => import('@pages/settings/CompanySettingsPage').then(m => ({ default: m.CompanySettingsPage }))); +const UsersSettingsPage = lazy(() => import('@pages/settings/UsersSettingsPage').then(m => ({ default: m.UsersSettingsPage }))); +const ProfileSettingsPage = lazy(() => import('@pages/settings/ProfileSettingsPage').then(m => ({ default: m.ProfileSettingsPage }))); +const SecuritySettingsPage = lazy(() => import('@pages/settings/SecuritySettingsPage').then(m => ({ default: m.SecuritySettingsPage }))); +const SystemSettingsPage = lazy(() => import('@pages/settings/SystemSettingsPage').then(m => ({ default: m.SystemSettingsPage }))); +const AuditLogsPage = lazy(() => import('@pages/settings/AuditLogsPage').then(m => ({ default: m.AuditLogsPage }))); + +// CRM pages +const PipelineKanbanPage = lazy(() => import('@pages/crm/PipelineKanbanPage').then(m => ({ default: m.PipelineKanbanPage }))); +const LeadsPage = lazy(() => import('@pages/crm/LeadsPage').then(m => ({ default: m.LeadsPage }))); +const OpportunitiesPage = lazy(() => import('@pages/crm/OpportunitiesPage').then(m => ({ default: m.OpportunitiesPage }))); + function LazyWrapper({ children }: { children: React.ReactNode }) { return }>{children}; } @@ -230,11 +244,40 @@ export const router = createBrowserRouter([ ), }, + // CRM routes + { + path: '/crm', + element: , + }, + { + path: '/crm/pipeline', + element: ( + + + + ), + }, + { + path: '/crm/leads', + element: ( + + + + ), + }, + { + path: '/crm/opportunities', + element: ( + + + + ), + }, { path: '/crm/*', element: ( -
Módulo CRM - En desarrollo
+
Seccion CRM - En desarrollo
), }, @@ -246,11 +289,68 @@ export const router = createBrowserRouter([ ), }, + // Settings routes + { + path: '/settings', + element: ( + + + + ), + }, + { + path: '/settings/company', + element: ( + + + + ), + }, + { + path: '/settings/users', + element: ( + + + + ), + }, + { + path: '/settings/profile', + element: ( + + + + ), + }, + { + path: '/settings/security', + element: ( + + + + ), + }, + { + path: '/settings/security/audit-logs', + element: ( + + + + ), + }, + { + path: '/settings/system', + element: ( + + + + ), + }, { path: '/settings/*', element: ( -
Configuración - En desarrollo
+
Seccion de Configuracion - En desarrollo
), }, diff --git a/src/pages/crm/LeadsPage.tsx b/src/pages/crm/LeadsPage.tsx index 87810f3..ba15613 100644 --- a/src/pages/crm/LeadsPage.tsx +++ b/src/pages/crm/LeadsPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Users, Plus, @@ -56,6 +57,7 @@ const formatCurrency = (value: number): string => { }; export function LeadsPage() { + const navigate = useNavigate(); const [selectedStatus, setSelectedStatus] = useState(''); const [selectedSource, setSelectedSource] = useState(''); const [searchTerm, setSearchTerm] = useState(''); @@ -86,7 +88,7 @@ export function LeadsPage() { key: 'view', label: 'Ver detalle', icon: , - onClick: () => console.log('View', lead.id), + onClick: () => navigate(`/crm/leads/${lead.id}`), }, ]; diff --git a/src/pages/crm/OpportunitiesPage.tsx b/src/pages/crm/OpportunitiesPage.tsx index 7f33e92..eccffcc 100644 --- a/src/pages/crm/OpportunitiesPage.tsx +++ b/src/pages/crm/OpportunitiesPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Target, Plus, @@ -41,6 +42,7 @@ const formatCurrency = (value: number): string => { }; export function OpportunitiesPage() { + const navigate = useNavigate(); const [selectedStatus, setSelectedStatus] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [oppToWin, setOppToWin] = useState(null); @@ -69,7 +71,7 @@ export function OpportunitiesPage() { key: 'view', label: 'Ver detalle', icon: , - onClick: () => console.log('View', opp.id), + onClick: () => navigate(`/crm/opportunities/${opp.id}`), }, ]; diff --git a/src/pages/crm/PipelineKanbanPage.tsx b/src/pages/crm/PipelineKanbanPage.tsx new file mode 100644 index 0000000..9227eb6 --- /dev/null +++ b/src/pages/crm/PipelineKanbanPage.tsx @@ -0,0 +1,430 @@ +import { useState, useMemo } from 'react'; +import { + Plus, + RefreshCw, + Search, + Filter, + List, +} from 'lucide-react'; +import { Button } from '@components/atoms/Button'; +import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; +import { DateRangePicker, type DateRange } from '@components/organisms/DatePicker'; +import { Select, type SelectOption } from '@components/organisms/Select'; +import { useToast } from '@components/organisms/Toast'; +import { KanbanColumn, type StageKey } from '@components/crm/KanbanColumn'; +import type { KanbanOpportunity } from '@components/crm/KanbanCard'; +import { formatNumber } from '@utils/formatters'; +import { Link } from 'react-router-dom'; + +// Stage labels for display +const stageLabels: Record = { + new: 'Nuevo', + qualified: 'Calificado', + proposition: 'Propuesta', + won: 'Ganado', + lost: 'Perdido', +}; + +// Mock users for filter +const mockUsers: SelectOption[] = [ + { value: 'user-1', label: 'Juan Garcia' }, + { value: 'user-2', label: 'Maria Lopez' }, + { value: 'user-3', label: 'Carlos Rodriguez' }, + { value: 'user-4', label: 'Ana Martinez' }, +]; + +// Mock data for opportunities +const generateMockOpportunities = (): Record => ({ + new: [ + { + id: '1', + name: 'Proyecto ERP para Constructora ABC', + expectedRevenue: 150000, + expectedCloseDate: '2026-02-15', + assignedTo: { id: 'user-1', name: 'Juan Garcia' }, + probability: 20, + partnerName: 'Constructora ABC S.A.', + }, + { + id: '2', + name: 'Sistema de Inventarios - Retail Plus', + expectedRevenue: 75000, + expectedCloseDate: '2026-02-28', + assignedTo: { id: 'user-2', name: 'Maria Lopez' }, + probability: 15, + partnerName: 'Retail Plus', + }, + { + id: '3', + name: 'Integracion CRM - TechCorp', + expectedRevenue: 45000, + probability: 10, + partnerName: 'TechCorp Internacional', + }, + ], + qualified: [ + { + id: '4', + name: 'Modulo de Facturacion Electronica', + expectedRevenue: 85000, + expectedCloseDate: '2026-02-10', + assignedTo: { id: 'user-1', name: 'Juan Garcia' }, + probability: 40, + partnerName: 'Distribuidora Nacional', + }, + { + id: '5', + name: 'Dashboard Ejecutivo - Financiera', + expectedRevenue: 120000, + expectedCloseDate: '2026-03-01', + assignedTo: { id: 'user-3', name: 'Carlos Rodriguez' }, + probability: 50, + partnerName: 'Financiera del Norte', + }, + ], + proposition: [ + { + id: '6', + name: 'Sistema de Gestion de Proyectos', + expectedRevenue: 200000, + expectedCloseDate: '2026-01-30', + assignedTo: { id: 'user-2', name: 'Maria Lopez' }, + probability: 65, + partnerName: 'Consultoria Global', + }, + { + id: '7', + name: 'Automatizacion de Procesos - Manufactura', + expectedRevenue: 180000, + expectedCloseDate: '2026-02-05', + assignedTo: { id: 'user-4', name: 'Ana Martinez' }, + probability: 70, + partnerName: 'Manufactura Industrial', + }, + { + id: '8', + name: 'Portal de Clientes Web', + expectedRevenue: 95000, + expectedCloseDate: '2026-02-20', + assignedTo: { id: 'user-1', name: 'Juan Garcia' }, + probability: 60, + partnerName: 'Servicios Express', + }, + ], + won: [ + { + id: '9', + name: 'Implementacion ERP Completo', + expectedRevenue: 350000, + expectedCloseDate: '2026-01-15', + assignedTo: { id: 'user-3', name: 'Carlos Rodriguez' }, + probability: 100, + partnerName: 'Grupo Industrial MX', + }, + { + id: '10', + name: 'Modulo de Recursos Humanos', + expectedRevenue: 65000, + expectedCloseDate: '2026-01-10', + assignedTo: { id: 'user-2', name: 'Maria Lopez' }, + probability: 100, + partnerName: 'Corporativo Sur', + }, + ], + lost: [ + { + id: '11', + name: 'Migracion de Sistema Legacy', + expectedRevenue: 125000, + expectedCloseDate: '2026-01-05', + assignedTo: { id: 'user-4', name: 'Ana Martinez' }, + probability: 0, + partnerName: 'Empresa Tradicional', + }, + ], +}); + +const formatCurrency = (value: number): string => { + return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +}; + +export function PipelineKanbanPage() { + const { success } = useToast(); + + // State for filters + const [searchTerm, setSearchTerm] = useState(''); + const [dateRange, setDateRange] = useState({ start: null, end: null }); + const [selectedUser, setSelectedUser] = useState(''); + const [showFilters, setShowFilters] = useState(false); + + // State for opportunities by stage + const [opportunitiesByStage, setOpportunitiesByStage] = useState>( + generateMockOpportunities + ); + + // Filter opportunities based on search and filters + const filteredOpportunities = useMemo(() => { + const result: Record = { + new: [], + qualified: [], + proposition: [], + won: [], + lost: [], + }; + + (Object.keys(opportunitiesByStage) as StageKey[]).forEach((stage) => { + result[stage] = opportunitiesByStage[stage].filter((opp) => { + // Search filter + if (searchTerm) { + const searchLower = searchTerm.toLowerCase(); + if ( + !opp.name.toLowerCase().includes(searchLower) && + !opp.partnerName?.toLowerCase().includes(searchLower) + ) { + return false; + } + } + + // User filter + if (selectedUser && opp.assignedTo?.id !== selectedUser) { + return false; + } + + // Date range filter + if (dateRange.start && dateRange.end && opp.expectedCloseDate) { + const closeDate = new Date(opp.expectedCloseDate); + if (closeDate < dateRange.start || closeDate > dateRange.end) { + return false; + } + } + + return true; + }); + }); + + return result; + }, [opportunitiesByStage, searchTerm, selectedUser, dateRange]); + + // Calculate totals + const totals = useMemo(() => { + const allOpps = Object.values(filteredOpportunities).flat(); + const openOpps = [...filteredOpportunities.new, ...filteredOpportunities.qualified, ...filteredOpportunities.proposition]; + + return { + totalOpportunities: allOpps.length, + totalValue: allOpps.reduce((sum, o) => sum + o.expectedRevenue, 0), + openValue: openOpps.reduce((sum, o) => sum + o.expectedRevenue, 0), + weightedValue: openOpps.reduce((sum, o) => sum + (o.expectedRevenue * o.probability / 100), 0), + wonValue: filteredOpportunities.won.reduce((sum, o) => sum + o.expectedRevenue, 0), + }; + }, [filteredOpportunities]); + + // Handle card drop (stage change) + const handleDrop = (opportunity: KanbanOpportunity, targetStage: StageKey) => { + // Find current stage + let currentStage: StageKey | null = null; + for (const [stage, items] of Object.entries(opportunitiesByStage)) { + if (items.some((item) => item.id === opportunity.id)) { + currentStage = stage as StageKey; + break; + } + } + + if (!currentStage || currentStage === targetStage) return; + + // Update state + setOpportunitiesByStage((prev) => { + const newState = { ...prev }; + + // Remove from current stage + newState[currentStage!] = prev[currentStage!].filter((item) => item.id !== opportunity.id); + + // Update probability based on stage + const updatedOpp = { ...opportunity }; + if (targetStage === 'won') { + updatedOpp.probability = 100; + } else if (targetStage === 'lost') { + updatedOpp.probability = 0; + } + + // Add to target stage + newState[targetStage] = [...prev[targetStage], updatedOpp]; + + return newState; + }); + + // Show toast notification + success( + 'Etapa actualizada', + `"${opportunity.name}" movida a ${stageLabels[targetStage]}` + ); + }; + + // Handle card click + const handleCardClick = (opportunity: KanbanOpportunity) => { + console.log('Card clicked:', opportunity); + // Here you could navigate to opportunity detail or open a modal + }; + + // Refresh data + const handleRefresh = () => { + setOpportunitiesByStage(generateMockOpportunities()); + success('Datos actualizados', 'El pipeline ha sido actualizado'); + }; + + // Clear filters + const handleClearFilters = () => { + setSearchTerm(''); + setDateRange({ start: null, end: null }); + setSelectedUser(''); + }; + + const hasActiveFilters = searchTerm || selectedUser || dateRange.start; + + return ( +
+ {/* Header */} +
+ + +
+
+

Pipeline de Ventas

+

+ Gestiona oportunidades arrastrando tarjetas entre etapas +

+
+
+ + + + + +
+
+ + {/* Summary Stats */} +
+
+
Oportunidades
+
{totals.totalOpportunities}
+
+
+
Pipeline Total
+
${formatCurrency(totals.openValue)}
+
+
+
Ponderado
+
${formatCurrency(totals.weightedValue)}
+
+
+
Ganado
+
${formatCurrency(totals.wonValue)}
+
+
+
Valor Total
+
${formatCurrency(totals.totalValue)}
+
+
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + {/* Toggle Filters */} + + + {hasActiveFilters && ( + + )} +
+ + {/* Expanded Filters */} + {showFilters && ( +
+
+ + + +

+ PNG, JPG o SVG. Maximo 2MB. +

+
+
+
+ + {/* Company Name */} +
+ + handleInputChange('name', e.target.value)} + placeholder="Nombre de la empresa" + /> +
+ + {/* Legal Name */} +
+ + handleInputChange('legalName', e.target.value)} + placeholder="Razon social completa" + /> +
+ + + + {/* Contact Information */} + + + + + Informacion de Contacto + + + +
+ + handleInputChange('phone', e.target.value)} + placeholder="+52 55 1234 5678" + /> +
+ +
+ + handleInputChange('email', e.target.value)} + placeholder="contacto@empresa.com" + /> +
+ +
+ + handleInputChange('website', e.target.value)} + placeholder="https://www.empresa.com" + /> +
+
+
+ + {/* Address */} + + + + + Direccion + + + +
+ + handleInputChange('street', e.target.value)} + placeholder="Av. Reforma 123, Col. Centro" + /> +
+ +
+
+ + handleInputChange('city', e.target.value)} + placeholder="Ciudad de Mexico" + /> +
+
+ + handleInputChange('state', e.target.value)} + placeholder="CDMX" + /> +
+
+ +
+
+ + handleInputChange('postalCode', e.target.value)} + placeholder="06000" + /> +
+
+ + handleInputChange('country', e.target.value)} + placeholder="Mexico" + /> +
+
+
+
+ + {/* Fiscal Information */} + + + + + Informacion Fiscal + + + +
+ + handleInputChange('taxId', e.target.value.toUpperCase())} + placeholder="XAXX010101000" + className="uppercase" + /> +
+ +
+ + +
+ +
+ + handleInputChange('fiscalAddress', e.target.value)} + placeholder="Domicilio fiscal completo" + /> +
+
+
+ + {/* Regional Settings */} + + + + + Configuracion Regional + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + +
+
+
+ ); +} + +export default CompanySettingsPage; diff --git a/src/pages/settings/ProfileSettingsPage.tsx b/src/pages/settings/ProfileSettingsPage.tsx new file mode 100644 index 0000000..7d2eb25 --- /dev/null +++ b/src/pages/settings/ProfileSettingsPage.tsx @@ -0,0 +1,549 @@ +import { useState } from 'react'; +import { + User, + Camera, + Save, + Mail, + Phone, + Lock, + Eye, + EyeOff, + Bell, + Globe, + Check, +} from 'lucide-react'; +import { Button } from '@components/atoms/Button'; +import { Input } from '@components/atoms/Input'; +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/molecules/Card'; +import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; + +interface ProfileFormData { + firstName: string; + lastName: string; + email: string; + phone: string; + avatar: string | null; + language: string; + timezone: string; +} + +interface PasswordFormData { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +interface NotificationSettings { + emailNotifications: boolean; + pushNotifications: boolean; + smsNotifications: boolean; + marketingEmails: boolean; + securityAlerts: boolean; + weeklyReport: boolean; + monthlyReport: boolean; +} + +const initialProfileData: ProfileFormData = { + firstName: 'Juan', + lastName: 'Perez', + email: 'juan.perez@miempresa.com', + phone: '+52 55 1234 5678', + avatar: null, + language: 'es-MX', + timezone: 'America/Mexico_City', +}; + +const initialPasswordData: PasswordFormData = { + currentPassword: '', + newPassword: '', + confirmPassword: '', +}; + +const initialNotifications: NotificationSettings = { + emailNotifications: true, + pushNotifications: true, + smsNotifications: false, + marketingEmails: false, + securityAlerts: true, + weeklyReport: true, + monthlyReport: false, +}; + +const languages = [ + { value: 'es-MX', label: 'Espanol (Mexico)' }, + { value: 'es-ES', label: 'Espanol (Espana)' }, + { value: 'en-US', label: 'English (US)' }, + { value: 'en-GB', label: 'English (UK)' }, + { value: 'pt-BR', label: 'Portugues (Brasil)' }, +]; + +const timezones = [ + { value: 'America/Mexico_City', label: '(UTC-06:00) Ciudad de Mexico' }, + { value: 'America/Tijuana', label: '(UTC-08:00) Tijuana' }, + { value: 'America/Cancun', label: '(UTC-05:00) Cancun' }, + { value: 'America/New_York', label: '(UTC-05:00) Nueva York' }, + { value: 'America/Los_Angeles', label: '(UTC-08:00) Los Angeles' }, + { value: 'Europe/Madrid', label: '(UTC+01:00) Madrid' }, + { value: 'Europe/London', label: '(UTC+00:00) Londres' }, +]; + +function ToggleSwitch({ + checked, + onChange, + label, + description, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + label: string; + description?: string; +}) { + return ( +
+
+
{label}
+ {description &&
{description}
} +
+ +
+ ); +} + +export function ProfileSettingsPage() { + const [profileData, setProfileData] = useState(initialProfileData); + const [passwordData, setPasswordData] = useState(initialPasswordData); + const [notifications, setNotifications] = useState(initialNotifications); + const [isSavingProfile, setIsSavingProfile] = useState(false); + const [isSavingPassword, setIsSavingPassword] = useState(false); + const [isSavingNotifications, setIsSavingNotifications] = useState(false); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [avatarPreview, setAvatarPreview] = useState(null); + + const handleProfileChange = (field: keyof ProfileFormData, value: string) => { + setProfileData((prev) => ({ ...prev, [field]: value })); + }; + + const handlePasswordChange = (field: keyof PasswordFormData, value: string) => { + setPasswordData((prev) => ({ ...prev, [field]: value })); + }; + + const handleNotificationChange = (field: keyof NotificationSettings, value: boolean) => { + setNotifications((prev) => ({ ...prev, [field]: value })); + }; + + const handleAvatarChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setAvatarPreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handleSaveProfile = async () => { + setIsSavingProfile(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIsSavingProfile(false); + alert('Perfil actualizado correctamente'); + }; + + const handleSavePassword = async () => { + if (passwordData.newPassword !== passwordData.confirmPassword) { + alert('Las contrasenas no coinciden'); + return; + } + if (passwordData.newPassword.length < 8) { + alert('La contrasena debe tener al menos 8 caracteres'); + return; + } + setIsSavingPassword(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIsSavingPassword(false); + setPasswordData(initialPasswordData); + alert('Contrasena actualizada correctamente'); + }; + + const handleSaveNotifications = async () => { + setIsSavingNotifications(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIsSavingNotifications(false); + alert('Preferencias de notificaciones actualizadas'); + }; + + const initials = `${profileData.firstName[0]}${profileData.lastName[0]}`.toUpperCase(); + + return ( +
+ + +
+

Mi Perfil

+

+ Administra tu informacion personal y preferencias +

+
+ +
+ {/* Profile Information */} + + + + + Informacion Personal + + + +
+ {/* Avatar */} +
+
+
+ {avatarPreview ? ( + Avatar preview + ) : ( + initials + )} +
+ +
+

Cambiar foto

+
+ + {/* Form Fields */} +
+
+ + handleProfileChange('firstName', e.target.value)} + placeholder="Tu nombre" + /> +
+ +
+ + handleProfileChange('lastName', e.target.value)} + placeholder="Tu apellido" + /> +
+ +
+ + handleProfileChange('email', e.target.value)} + placeholder="tu@email.com" + /> +
+ +
+ + handleProfileChange('phone', e.target.value)} + placeholder="+52 55 1234 5678" + /> +
+
+
+
+ + + +
+ + {/* Change Password */} + + + + + Cambiar Contrasena + + + +
+ +
+ handlePasswordChange('currentPassword', e.target.value)} + placeholder="Tu contrasena actual" + /> + +
+
+ +
+ +
+ handlePasswordChange('newPassword', e.target.value)} + placeholder="Minimo 8 caracteres" + /> + +
+
+ +
+ +
+ handlePasswordChange('confirmPassword', e.target.value)} + placeholder="Repite la nueva contrasena" + /> + +
+ {passwordData.newPassword && + passwordData.confirmPassword && + passwordData.newPassword !== passwordData.confirmPassword && ( +

+ Las contrasenas no coinciden +

+ )} + {passwordData.newPassword && + passwordData.confirmPassword && + passwordData.newPassword === passwordData.confirmPassword && ( +

+ + Las contrasenas coinciden +

+ )} +
+
+ + + +
+ + {/* Locale Settings */} + + + + + Idioma y Region + + + +
+ + +
+ +
+ + +
+
+ + + +
+ + {/* Notification Settings */} + + + + + Notificaciones + + + +
+
+

Canales de notificacion

+ handleNotificationChange('emailNotifications', value)} + label="Notificaciones por email" + description="Recibe alertas y actualizaciones por correo" + /> + handleNotificationChange('pushNotifications', value)} + label="Notificaciones push" + description="Notificaciones en el navegador" + /> + handleNotificationChange('smsNotifications', value)} + label="Notificaciones SMS" + description="Recibe alertas por mensaje de texto" + /> +
+ +
+

Tipos de notificacion

+ handleNotificationChange('securityAlerts', value)} + label="Alertas de seguridad" + description="Inicios de sesion y cambios de contrasena" + /> + handleNotificationChange('weeklyReport', value)} + label="Reporte semanal" + description="Resumen de actividad semanal" + /> + handleNotificationChange('monthlyReport', value)} + label="Reporte mensual" + description="Resumen de actividad mensual" + /> + handleNotificationChange('marketingEmails', value)} + label="Emails de marketing" + description="Novedades, ofertas y actualizaciones" + /> +
+
+
+ + + +
+
+
+ ); +} + +export default ProfileSettingsPage; diff --git a/src/pages/settings/SecuritySettingsPage.tsx b/src/pages/settings/SecuritySettingsPage.tsx new file mode 100644 index 0000000..0e39fcb --- /dev/null +++ b/src/pages/settings/SecuritySettingsPage.tsx @@ -0,0 +1,662 @@ +import { useState } from 'react'; +import { + Shield, + Smartphone, + Key, + Monitor, + LogOut, + MapPin, + Clock, + Check, + X, + AlertTriangle, + Trash2, + QrCode, + Copy, +} from 'lucide-react'; +import { Button } from '@components/atoms/Button'; +import { Input } from '@components/atoms/Input'; +import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card'; +import { DataTable, type Column } from '@components/organisms/DataTable'; +import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; +import { ConfirmModal } from '@components/organisms/Modal'; +import { formatDate } from '@utils/formatters'; + +interface Session { + id: string; + device: string; + browser: string; + location: string; + ipAddress: string; + lastActive: string; + isCurrent: boolean; +} + +interface LoginHistory { + id: string; + date: string; + ipAddress: string; + location: string; + device: string; + browser: string; + status: 'success' | 'failed'; +} + +interface AuthorizedDevice { + id: string; + name: string; + type: 'desktop' | 'mobile' | 'tablet'; + lastUsed: string; + trusted: boolean; +} + +const mockSessions: Session[] = [ + { + id: '1', + device: 'MacBook Pro', + browser: 'Chrome 120', + location: 'Ciudad de Mexico, Mexico', + ipAddress: '189.203.xxx.xxx', + lastActive: new Date().toISOString(), + isCurrent: true, + }, + { + id: '2', + device: 'iPhone 15', + browser: 'Safari Mobile', + location: 'Ciudad de Mexico, Mexico', + ipAddress: '187.190.xxx.xxx', + lastActive: new Date(Date.now() - 3600000).toISOString(), + isCurrent: false, + }, + { + id: '3', + device: 'Windows PC', + browser: 'Edge 120', + location: 'Guadalajara, Mexico', + ipAddress: '201.175.xxx.xxx', + lastActive: new Date(Date.now() - 86400000).toISOString(), + isCurrent: false, + }, +]; + +const mockLoginHistory: LoginHistory[] = [ + { + id: '1', + date: new Date().toISOString(), + ipAddress: '189.203.xxx.xxx', + location: 'Ciudad de Mexico, Mexico', + device: 'MacBook Pro', + browser: 'Chrome 120', + status: 'success', + }, + { + id: '2', + date: new Date(Date.now() - 7200000).toISOString(), + ipAddress: '189.203.xxx.xxx', + location: 'Ciudad de Mexico, Mexico', + device: 'MacBook Pro', + browser: 'Chrome 120', + status: 'success', + }, + { + id: '3', + date: new Date(Date.now() - 14400000).toISOString(), + ipAddress: '200.100.xxx.xxx', + location: 'Ubicacion desconocida', + device: 'Unknown', + browser: 'Unknown', + status: 'failed', + }, + { + id: '4', + date: new Date(Date.now() - 86400000).toISOString(), + ipAddress: '187.190.xxx.xxx', + location: 'Ciudad de Mexico, Mexico', + device: 'iPhone 15', + browser: 'Safari Mobile', + status: 'success', + }, + { + id: '5', + date: new Date(Date.now() - 172800000).toISOString(), + ipAddress: '201.175.xxx.xxx', + location: 'Guadalajara, Mexico', + device: 'Windows PC', + browser: 'Edge 120', + status: 'success', + }, + { + id: '6', + date: new Date(Date.now() - 259200000).toISOString(), + ipAddress: '189.203.xxx.xxx', + location: 'Ciudad de Mexico, Mexico', + device: 'MacBook Pro', + browser: 'Chrome 120', + status: 'success', + }, + { + id: '7', + date: new Date(Date.now() - 345600000).toISOString(), + ipAddress: '189.203.xxx.xxx', + location: 'Ciudad de Mexico, Mexico', + device: 'MacBook Pro', + browser: 'Firefox 121', + status: 'success', + }, + { + id: '8', + date: new Date(Date.now() - 432000000).toISOString(), + ipAddress: '123.456.xxx.xxx', + location: 'Ubicacion desconocida', + device: 'Unknown', + browser: 'Unknown', + status: 'failed', + }, + { + id: '9', + date: new Date(Date.now() - 518400000).toISOString(), + ipAddress: '187.190.xxx.xxx', + location: 'Ciudad de Mexico, Mexico', + device: 'iPhone 15', + browser: 'Safari Mobile', + status: 'success', + }, + { + id: '10', + date: new Date(Date.now() - 604800000).toISOString(), + ipAddress: '189.203.xxx.xxx', + location: 'Ciudad de Mexico, Mexico', + device: 'MacBook Pro', + browser: 'Chrome 119', + status: 'success', + }, +]; + +const mockAuthorizedDevices: AuthorizedDevice[] = [ + { + id: '1', + name: 'MacBook Pro de Juan', + type: 'desktop', + lastUsed: new Date().toISOString(), + trusted: true, + }, + { + id: '2', + name: 'iPhone de Juan', + type: 'mobile', + lastUsed: new Date(Date.now() - 3600000).toISOString(), + trusted: true, + }, + { + id: '3', + name: 'iPad Pro', + type: 'tablet', + lastUsed: new Date(Date.now() - 604800000).toISOString(), + trusted: false, + }, +]; + +export function SecuritySettingsPage() { + const [is2FAEnabled, setIs2FAEnabled] = useState(false); + const [show2FASetup, setShow2FASetup] = useState(false); + const [verificationCode, setVerificationCode] = useState(''); + const [sessions] = useState(mockSessions); + const [loginHistory] = useState(mockLoginHistory); + const [authorizedDevices, setAuthorizedDevices] = useState(mockAuthorizedDevices); + const [sessionToRevoke, setSessionToRevoke] = useState(null); + const [deviceToRemove, setDeviceToRemove] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const mock2FASecret = 'JBSWY3DPEHPK3PXP'; + + const handleEnable2FA = () => { + setShow2FASetup(true); + }; + + const handleVerify2FA = async () => { + if (verificationCode.length !== 6) { + alert('El codigo debe tener 6 digitos'); + return; + } + setIsLoading(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIs2FAEnabled(true); + setShow2FASetup(false); + setVerificationCode(''); + setIsLoading(false); + alert('Autenticacion de dos factores activada correctamente'); + }; + + const handleDisable2FA = async () => { + setIsLoading(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIs2FAEnabled(false); + setIsLoading(false); + alert('Autenticacion de dos factores desactivada'); + }; + + const handleRevokeSession = async () => { + if (sessionToRevoke) { + setIsLoading(true); + await new Promise((resolve) => setTimeout(resolve, 500)); + setIsLoading(false); + setSessionToRevoke(null); + alert(`Sesion en ${sessionToRevoke.device} cerrada`); + } + }; + + const handleRevokeAllSessions = async () => { + setIsLoading(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIsLoading(false); + alert('Todas las sesiones han sido cerradas (excepto la actual)'); + }; + + const handleRemoveDevice = async () => { + if (deviceToRemove) { + setAuthorizedDevices((prev) => prev.filter((d) => d.id !== deviceToRemove.id)); + setDeviceToRemove(null); + alert(`Dispositivo "${deviceToRemove.name}" eliminado`); + } + }; + + const handleCopySecret = () => { + navigator.clipboard.writeText(mock2FASecret); + alert('Codigo secreto copiado al portapapeles'); + }; + + const getDeviceIcon = (type: AuthorizedDevice['type']) => { + switch (type) { + case 'desktop': + return ; + case 'mobile': + return ; + case 'tablet': + return ; + default: + return ; + } + }; + + const sessionColumns: Column[] = [ + { + key: 'device', + header: 'Dispositivo', + render: (session) => ( +
+
+ +
+
+
+ {session.device} + {session.isCurrent && ( + + Actual + + )} +
+
{session.browser}
+
+
+ ), + }, + { + key: 'location', + header: 'Ubicacion', + render: (session) => ( +
+ +
+
{session.location}
+
{session.ipAddress}
+
+
+ ), + }, + { + key: 'lastActive', + header: 'Ultima actividad', + render: (session) => ( +
+ + {session.isCurrent ? 'Ahora' : formatDate(session.lastActive, 'short')} +
+ ), + }, + { + key: 'actions', + header: '', + render: (session) => + !session.isCurrent && ( + + ), + }, + ]; + + const loginHistoryColumns: Column[] = [ + { + key: 'date', + header: 'Fecha', + render: (log) => ( + + {formatDate(log.date, 'full')} + + ), + }, + { + key: 'status', + header: 'Estado', + render: (log) => ( + + {log.status === 'success' ? ( + <> + + Exitoso + + ) : ( + <> + + Fallido + + )} + + ), + }, + { + key: 'location', + header: 'Ubicacion', + render: (log) => ( +
+ + {log.location} +
+ ), + }, + { + key: 'device', + header: 'Dispositivo', + render: (log) => ( +
+ {log.device} / {log.browser} +
+ ), + }, + { + key: 'ipAddress', + header: 'IP', + render: (log) => ( + {log.ipAddress} + ), + }, + ]; + + return ( +
+ + +
+
+

Seguridad

+

+ Administra la seguridad de tu cuenta +

+
+ +
+ + {/* Two-Factor Authentication */} + + + + + Autenticacion de Dos Factores (2FA) + + + + {!show2FASetup ? ( +
+
+
+ +
+
+
+ {is2FAEnabled + ? 'Autenticacion de dos factores activada' + : 'Autenticacion de dos factores desactivada'} +
+
+ {is2FAEnabled + ? 'Tu cuenta esta protegida con un segundo factor de autenticacion' + : 'Anade una capa extra de seguridad a tu cuenta'} +
+
+
+ +
+ ) : ( +
+
+ +
+

Configura tu aplicacion de autenticacion

+

+ Escanea el codigo QR con tu aplicacion de autenticacion (Google Authenticator, Authy, etc.) + o ingresa el codigo secreto manualmente. +

+
+
+ +
+ {/* QR Code placeholder */} +
+
+ +

Codigo QR

+
+
+ +
+
+ +
+ + {mock2FASecret} + + +
+
+ +
+ +
+ setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + className="font-mono text-center text-lg tracking-widest" + maxLength={6} + /> + +
+

+ Ingresa el codigo de 6 digitos de tu aplicacion +

+
+
+
+ +
+ +
+
+ )} +
+
+ + {/* Active Sessions */} + + +
+ + + Sesiones Activas + + +
+
+ + + +
+ + {/* Login History */} + + + + + Historial de Inicios de Sesion + + + +

+ Ultimos 10 intentos de inicio de sesion +

+ +
+
+ + {/* Authorized Devices */} + + + + + Dispositivos Autorizados + + + +
+ {authorizedDevices.map((device) => ( +
+
+
+ {getDeviceIcon(device.type)} +
+
+
+ {device.name} + {device.trusted && ( + + + Confiable + + )} +
+
+ Ultimo uso: {formatDate(device.lastUsed, 'short')} +
+
+
+ +
+ ))} +
+
+
+ + {/* Revoke Session Modal */} + setSessionToRevoke(null)} + onConfirm={handleRevokeSession} + title="Cerrar sesion" + message={`¿Cerrar la sesion en "${sessionToRevoke?.device}"? El usuario tendra que volver a iniciar sesion.`} + variant="warning" + confirmText="Cerrar sesion" + /> + + {/* Remove Device Modal */} + setDeviceToRemove(null)} + onConfirm={handleRemoveDevice} + title="Eliminar dispositivo" + message={`¿Eliminar "${deviceToRemove?.name}" de la lista de dispositivos autorizados? El dispositivo tendra que volver a autenticarse.`} + variant="danger" + confirmText="Eliminar" + /> +
+ ); +} + +export default SecuritySettingsPage; diff --git a/src/pages/settings/SystemSettingsPage.tsx b/src/pages/settings/SystemSettingsPage.tsx new file mode 100644 index 0000000..c5b90d8 --- /dev/null +++ b/src/pages/settings/SystemSettingsPage.tsx @@ -0,0 +1,923 @@ +import { useState } from 'react'; +import { + Database, + Download, + Upload, + FileText, + Key, + Plus, + Copy, + Eye, + EyeOff, + Trash2, + Check, + X, + AlertTriangle, + Clock, + HardDrive, + Cpu, + Activity, + Package, + ToggleLeft, + ToggleRight, + Calendar, + Filter, +} from 'lucide-react'; +import { Button } from '@components/atoms/Button'; +import { Input } from '@components/atoms/Input'; +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/molecules/Card'; +import { DataTable, type Column } from '@components/organisms/DataTable'; +import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; +import { ConfirmModal } from '@components/organisms/Modal'; +import { formatDate } from '@utils/formatters'; + +interface BackupConfig { + autoBackup: boolean; + frequency: 'daily' | 'weekly' | 'monthly'; + retentionDays: number; + lastBackup: string | null; + nextBackup: string | null; + storageLocation: string; +} + +interface SystemLog { + id: string; + timestamp: string; + level: 'info' | 'warning' | 'error'; + source: string; + message: string; +} + +interface ApiKey { + id: string; + name: string; + key: string; + createdAt: string; + lastUsed: string | null; + permissions: string[]; + isActive: boolean; +} + +interface Module { + id: string; + name: string; + description: string; + version: string; + isActive: boolean; + isCore: boolean; +} + +const initialBackupConfig: BackupConfig = { + autoBackup: true, + frequency: 'daily', + retentionDays: 30, + lastBackup: new Date(Date.now() - 86400000).toISOString(), + nextBackup: new Date(Date.now() + 43200000).toISOString(), + storageLocation: 'cloud', +}; + +const mockSystemLogs: SystemLog[] = [ + { + id: '1', + timestamp: new Date().toISOString(), + level: 'info', + source: 'auth', + message: 'Usuario juan.perez@miempresa.com inicio sesion exitosamente', + }, + { + id: '2', + timestamp: new Date(Date.now() - 300000).toISOString(), + level: 'warning', + source: 'database', + message: 'Conexion a base de datos lenta (>500ms)', + }, + { + id: '3', + timestamp: new Date(Date.now() - 600000).toISOString(), + level: 'info', + source: 'backup', + message: 'Respaldo automatico completado exitosamente', + }, + { + id: '4', + timestamp: new Date(Date.now() - 900000).toISOString(), + level: 'error', + source: 'email', + message: 'Error al enviar notificacion: SMTP timeout', + }, + { + id: '5', + timestamp: new Date(Date.now() - 1200000).toISOString(), + level: 'info', + source: 'system', + message: 'Modulo de inventario actualizado a v2.1.0', + }, + { + id: '6', + timestamp: new Date(Date.now() - 1500000).toISOString(), + level: 'warning', + source: 'storage', + message: 'Uso de almacenamiento al 75%', + }, + { + id: '7', + timestamp: new Date(Date.now() - 1800000).toISOString(), + level: 'info', + source: 'auth', + message: 'Token de API generado para integracion externa', + }, + { + id: '8', + timestamp: new Date(Date.now() - 2100000).toISOString(), + level: 'error', + source: 'api', + message: 'Rate limit excedido para IP 192.168.1.100', + }, + { + id: '9', + timestamp: new Date(Date.now() - 2400000).toISOString(), + level: 'info', + source: 'database', + message: 'Migracion de base de datos completada', + }, + { + id: '10', + timestamp: new Date(Date.now() - 2700000).toISOString(), + level: 'info', + source: 'system', + message: 'Sistema iniciado correctamente', + }, +]; + +const mockApiKeys: ApiKey[] = [ + { + id: '1', + name: 'Integracion Shopify', + key: 'sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + createdAt: new Date(Date.now() - 2592000000).toISOString(), + lastUsed: new Date(Date.now() - 3600000).toISOString(), + permissions: ['read:products', 'write:orders'], + isActive: true, + }, + { + id: '2', + name: 'API Movil', + key: 'sk_live_yyyyyyyyyyyyyyyyyyyyyyyyyyy', + createdAt: new Date(Date.now() - 5184000000).toISOString(), + lastUsed: new Date().toISOString(), + permissions: ['read:all', 'write:all'], + isActive: true, + }, + { + id: '3', + name: 'Webhook Pagos', + key: 'sk_live_zzzzzzzzzzzzzzzzzzzzzzzzzzz', + createdAt: new Date(Date.now() - 7776000000).toISOString(), + lastUsed: null, + permissions: ['write:payments'], + isActive: false, + }, +]; + +const mockModules: Module[] = [ + { + id: '1', + name: 'Core', + description: 'Funcionalidades basicas del sistema', + version: '1.0.0', + isActive: true, + isCore: true, + }, + { + id: '2', + name: 'Inventario', + description: 'Gestion de productos y stock', + version: '2.1.0', + isActive: true, + isCore: false, + }, + { + id: '3', + name: 'Ventas', + description: 'Punto de venta y facturacion', + version: '1.5.0', + isActive: true, + isCore: false, + }, + { + id: '4', + name: 'Compras', + description: 'Ordenes de compra y proveedores', + version: '1.3.0', + isActive: true, + isCore: false, + }, + { + id: '5', + name: 'Contabilidad', + description: 'Plan de cuentas y asientos contables', + version: '1.2.0', + isActive: false, + isCore: false, + }, + { + id: '6', + name: 'CRM', + description: 'Gestion de clientes y oportunidades', + version: '1.0.0', + isActive: false, + isCore: false, + }, + { + id: '7', + name: 'Proyectos', + description: 'Gestion de proyectos y tareas', + version: '1.0.0', + isActive: false, + isCore: false, + }, + { + id: '8', + name: 'RRHH', + description: 'Recursos humanos y nomina', + version: '0.9.0', + isActive: false, + isCore: false, + }, +]; + +export function SystemSettingsPage() { + const [backupConfig, setBackupConfig] = useState(initialBackupConfig); + const [systemLogs] = useState(mockSystemLogs); + const [apiKeys, setApiKeys] = useState(mockApiKeys); + const [modules, setModules] = useState(mockModules); + const [isBackingUp, setIsBackingUp] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + const [showNewApiKeyModal, setShowNewApiKeyModal] = useState(false); + const [newApiKeyName, setNewApiKeyName] = useState(''); + const [generatedApiKey, setGeneratedApiKey] = useState(null); + const [visibleKeys, setVisibleKeys] = useState>(new Set()); + const [apiKeyToDelete, setApiKeyToDelete] = useState(null); + const [logLevelFilter, setLogLevelFilter] = useState(''); + const [logSourceFilter, setLogSourceFilter] = useState(''); + + const handleBackupNow = async () => { + setIsBackingUp(true); + await new Promise((resolve) => setTimeout(resolve, 3000)); + setBackupConfig((prev) => ({ + ...prev, + lastBackup: new Date().toISOString(), + })); + setIsBackingUp(false); + alert('Respaldo completado exitosamente'); + }; + + const handleRestore = async () => { + setIsRestoring(true); + await new Promise((resolve) => setTimeout(resolve, 2000)); + setIsRestoring(false); + alert('Por favor selecciona un archivo de respaldo para restaurar'); + }; + + const handleBackupConfigChange = (field: keyof BackupConfig, value: unknown) => { + setBackupConfig((prev) => ({ ...prev, [field]: value })); + }; + + const handleCreateApiKey = async () => { + if (!newApiKeyName.trim()) { + alert('Ingresa un nombre para la API key'); + return; + } + const newKey = `sk_live_${Math.random().toString(36).substring(2, 32)}`; + const newApiKey: ApiKey = { + id: String(apiKeys.length + 1), + name: newApiKeyName, + key: newKey, + createdAt: new Date().toISOString(), + lastUsed: null, + permissions: ['read:all'], + isActive: true, + }; + setApiKeys((prev) => [...prev, newApiKey]); + setGeneratedApiKey(newKey); + }; + + const handleCloseNewApiKeyModal = () => { + setShowNewApiKeyModal(false); + setNewApiKeyName(''); + setGeneratedApiKey(null); + }; + + const handleCopyApiKey = (key: string) => { + navigator.clipboard.writeText(key); + alert('API key copiada al portapapeles'); + }; + + const toggleKeyVisibility = (keyId: string) => { + setVisibleKeys((prev) => { + const newSet = new Set(prev); + if (newSet.has(keyId)) { + newSet.delete(keyId); + } else { + newSet.add(keyId); + } + return newSet; + }); + }; + + const handleDeleteApiKey = async () => { + if (apiKeyToDelete) { + setApiKeys((prev) => prev.filter((k) => k.id !== apiKeyToDelete.id)); + setApiKeyToDelete(null); + alert(`API key "${apiKeyToDelete.name}" eliminada`); + } + }; + + const handleToggleModule = (moduleId: string) => { + setModules((prev) => + prev.map((m) => + m.id === moduleId && !m.isCore ? { ...m, isActive: !m.isActive } : m + ) + ); + }; + + const getLevelIcon = (level: SystemLog['level']) => { + switch (level) { + case 'info': + return ; + case 'warning': + return ; + case 'error': + return ; + } + }; + + const getLevelColor = (level: SystemLog['level']) => { + switch (level) { + case 'info': + return 'bg-blue-100 text-blue-700'; + case 'warning': + return 'bg-yellow-100 text-yellow-700'; + case 'error': + return 'bg-red-100 text-red-700'; + } + }; + + const filteredLogs = systemLogs.filter((log) => { + if (logLevelFilter && log.level !== logLevelFilter) return false; + if (logSourceFilter && log.source !== logSourceFilter) return false; + return true; + }); + + const uniqueSources = [...new Set(systemLogs.map((l) => l.source))]; + + const logColumns: Column[] = [ + { + key: 'timestamp', + header: 'Fecha', + render: (log) => ( + + {formatDate(log.timestamp, 'full')} + + ), + }, + { + key: 'level', + header: 'Nivel', + render: (log) => ( + + {getLevelIcon(log.level)} + {log.level.toUpperCase()} + + ), + }, + { + key: 'source', + header: 'Fuente', + render: (log) => ( + + {log.source} + + ), + }, + { + key: 'message', + header: 'Mensaje', + render: (log) => {log.message}, + }, + ]; + + const apiKeyColumns: Column[] = [ + { + key: 'name', + header: 'Nombre', + render: (apiKey) => ( +
+
{apiKey.name}
+
+ Creada: {formatDate(apiKey.createdAt, 'short')} +
+
+ ), + }, + { + key: 'key', + header: 'API Key', + render: (apiKey) => ( +
+ + {visibleKeys.has(apiKey.id) + ? apiKey.key + : `${apiKey.key.slice(0, 10)}${'*'.repeat(20)}`} + + + +
+ ), + }, + { + key: 'lastUsed', + header: 'Ultimo uso', + render: (apiKey) => ( + + {apiKey.lastUsed ? formatDate(apiKey.lastUsed, 'short') : 'Nunca'} + + ), + }, + { + key: 'isActive', + header: 'Estado', + render: (apiKey) => ( + + {apiKey.isActive ? ( + <> + + Activa + + ) : ( + <> + + Inactiva + + )} + + ), + }, + { + key: 'actions', + header: '', + render: (apiKey) => ( + + ), + }, + ]; + + return ( +
+ + +
+

Configuracion del Sistema

+

+ Administra respaldos, logs, API keys y modulos +

+
+ + {/* System Stats */} +
+ + +
+
+ +
+
+
Base de datos
+
2.3 GB
+
+
+
+
+ + + +
+
+ +
+
+
Almacenamiento
+
75%
+
+
+
+
+ + + +
+
+ +
+
+
CPU
+
23%
+
+
+
+
+ + + +
+
+ +
+
+
Uptime
+
99.9%
+
+
+
+
+
+ + {/* Backup Configuration */} + + + + + Respaldos + + + +
+
+
+
+
Respaldo automatico
+
+ Realiza respaldos automaticos de la base de datos +
+
+ +
+ + {backupConfig.autoBackup && ( + <> +
+ + +
+ +
+ + + handleBackupConfigChange('retentionDays', parseInt(e.target.value)) + } + min={7} + max={365} + /> +
+ + )} +
+ +
+
+ +
+
Ultimo respaldo
+
+ {backupConfig.lastBackup + ? formatDate(backupConfig.lastBackup, 'full') + : 'Nunca'} +
+
+
+ + {backupConfig.autoBackup && backupConfig.nextBackup && ( +
+ +
+
Proximo respaldo
+
+ {formatDate(backupConfig.nextBackup, 'full')} +
+
+
+ )} + +
+ + +
+
+
+
+
+ + {/* System Logs */} + + +
+ + + Logs del Sistema + + +
+
+ +
+ {/* Filters */} +
+
+ + +
+ +
+ + +
+ + {(logLevelFilter || logSourceFilter) && ( + + )} +
+ + +
+
+
+ + {/* API Keys */} + + +
+ + + API Keys + + +
+
+ +

+ Gestiona las API keys para integraciones externas +

+ +
+
+ + {/* Active Modules */} + + + + + Modulos Activos + + + +
+ {modules.map((module) => ( +
+
+
+
+ {module.name} + {module.isCore && ( + + Core + + )} +
+
v{module.version}
+
{module.description}
+
+ {!module.isCore && ( + + )} +
+
+ ))} +
+
+
+ + {/* New API Key Modal */} + {showNewApiKeyModal && ( +
+ + + + + {generatedApiKey ? 'API Key Generada' : 'Nueva API Key'} + + + + {!generatedApiKey ? ( +
+ + setNewApiKeyName(e.target.value)} + placeholder="Ej: Integracion Shopify" + /> +

+ Un nombre descriptivo para identificar esta API key +

+
+ ) : ( +
+
+ +
+

Guarda esta API key de forma segura

+

+ Esta es la unica vez que veras esta API key completa. No podras verla de nuevo. +

+
+
+
+ +
+ + {generatedApiKey} + + +
+
+
+ )} +
+ + + {!generatedApiKey && ( + + )} + +
+
+ )} + + {/* Delete API Key Modal */} + setApiKeyToDelete(null)} + onConfirm={handleDeleteApiKey} + title="Eliminar API Key" + message={`¿Eliminar la API key "${apiKeyToDelete?.name}"? Las integraciones que usen esta key dejaran de funcionar.`} + variant="danger" + confirmText="Eliminar" + /> +
+ ); +} + +export default SystemSettingsPage; diff --git a/src/pages/settings/UsersSettingsPage.tsx b/src/pages/settings/UsersSettingsPage.tsx index f248082..4d58495 100644 --- a/src/pages/settings/UsersSettingsPage.tsx +++ b/src/pages/settings/UsersSettingsPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Users as UsersIcon, Plus, @@ -34,6 +35,7 @@ const roleColors: Record = { }; export function UsersSettingsPage() { + const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(''); const [selectedRole, setSelectedRole] = useState(''); const [showActiveOnly, setShowActiveOnly] = useState(undefined); @@ -157,13 +159,14 @@ export function UsersSettingsPage() { key: 'view', label: 'Ver detalle', icon: , - onClick: () => console.log('View', user.id), + // TODO: Implement user detail modal - setShowUserDetailModal(true); setSelectedUser(user); + onClick: () => navigate(`/settings/users/${user.id}`), }, { key: 'edit', label: 'Editar', icon: , - onClick: () => console.log('Edit', user.id), + onClick: () => navigate(`/settings/users/${user.id}/edit`), }, { key: 'toggle', diff --git a/src/shared/components/crm/KanbanCard.tsx b/src/shared/components/crm/KanbanCard.tsx new file mode 100644 index 0000000..aa3a615 --- /dev/null +++ b/src/shared/components/crm/KanbanCard.tsx @@ -0,0 +1,118 @@ +import { DragEvent } from 'react'; +import { Calendar, DollarSign } from 'lucide-react'; +import { Avatar } from '@components/atoms/Avatar'; +import { formatNumber } from '@utils/formatters'; +import { cn } from '@utils/cn'; + +export interface KanbanOpportunity { + id: string; + name: string; + expectedRevenue: number; + expectedCloseDate?: string; + assignedTo?: { + id: string; + name: string; + avatarUrl?: string; + }; + probability: number; + partnerName?: string; +} + +export interface KanbanCardProps { + opportunity: KanbanOpportunity; + onDragStart: (e: DragEvent, opportunity: KanbanOpportunity) => void; + onClick?: (opportunity: KanbanOpportunity) => void; +} + +const formatCurrency = (value: number): string => { + return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +}; + +const formatDate = (dateStr: string): string => { + const date = new Date(dateStr); + return date.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' }); +}; + +export function KanbanCard({ opportunity, onDragStart, onClick }: KanbanCardProps) { + const handleDragStart = (e: DragEvent) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('application/json', JSON.stringify(opportunity)); + onDragStart(e, opportunity); + }; + + return ( +
onClick?.(opportunity)} + className={cn( + 'cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all', + 'hover:shadow-md hover:border-gray-300', + 'active:cursor-grabbing active:shadow-lg' + )} + > + {/* Opportunity Name */} +
+

{opportunity.name}

+ {opportunity.partnerName && ( +

{opportunity.partnerName}

+ )} +
+ + {/* Expected Revenue */} +
+ + + ${formatCurrency(opportunity.expectedRevenue)} + +
+ + {/* Footer: Date and Avatar */} +
+ {/* Expected Close Date */} + {opportunity.expectedCloseDate ? ( +
+ + {formatDate(opportunity.expectedCloseDate)} +
+ ) : ( +
+ + Sin fecha +
+ )} + + {/* Assigned User Avatar */} + {opportunity.assignedTo ? ( + + ) : ( + + )} +
+ + {/* Probability indicator */} +
+
+ Probabilidad + {opportunity.probability}% +
+
+
= 70 ? 'bg-green-500' : + opportunity.probability >= 40 ? 'bg-yellow-500' : 'bg-red-500' + )} + style={{ width: `${opportunity.probability}%` }} + /> +
+
+
+ ); +} + +export default KanbanCard; diff --git a/src/shared/components/crm/KanbanColumn.tsx b/src/shared/components/crm/KanbanColumn.tsx new file mode 100644 index 0000000..1ec9a31 --- /dev/null +++ b/src/shared/components/crm/KanbanColumn.tsx @@ -0,0 +1,154 @@ +import { DragEvent, useState } from 'react'; +import { DollarSign } from 'lucide-react'; +import { KanbanCard, type KanbanOpportunity } from './KanbanCard'; +import { formatNumber } from '@utils/formatters'; +import { cn } from '@utils/cn'; + +export type StageKey = 'new' | 'qualified' | 'proposition' | 'won' | 'lost'; + +export interface KanbanColumnProps { + title: string; + stage: StageKey; + items: KanbanOpportunity[]; + onDrop: (opportunity: KanbanOpportunity, targetStage: StageKey) => void; + onCardClick?: (opportunity: KanbanOpportunity) => void; +} + +const stageColors: Record = { + new: { + bg: 'bg-blue-50', + border: 'border-blue-200', + header: 'bg-blue-100', + text: 'text-blue-700', + }, + qualified: { + bg: 'bg-indigo-50', + border: 'border-indigo-200', + header: 'bg-indigo-100', + text: 'text-indigo-700', + }, + proposition: { + bg: 'bg-amber-50', + border: 'border-amber-200', + header: 'bg-amber-100', + text: 'text-amber-700', + }, + won: { + bg: 'bg-green-50', + border: 'border-green-200', + header: 'bg-green-100', + text: 'text-green-700', + }, + lost: { + bg: 'bg-red-50', + border: 'border-red-200', + header: 'bg-red-100', + text: 'text-red-700', + }, +}; + +const formatCurrency = (value: number): string => { + return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +}; + +export function KanbanColumn({ title, stage, items, onDrop, onCardClick }: KanbanColumnProps) { + const [isDragOver, setIsDragOver] = useState(false); + + const colors = stageColors[stage]; + + // Calculate totals + const totalCount = items.length; + const totalValue = items.reduce((sum, item) => sum + (item.expectedRevenue || 0), 0); + + // Pass-through for drag start - actual data transfer happens in KanbanCard + const handleDragStart = (_e: DragEvent, _opportunity: KanbanOpportunity) => { + // Data transfer is handled by KanbanCard + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + + try { + const data = e.dataTransfer.getData('application/json'); + const opportunity: KanbanOpportunity = JSON.parse(data); + onDrop(opportunity, stage); + } catch (err) { + console.error('Error parsing dropped data:', err); + } + }; + + return ( +
+ {/* Column Header */} +
+
+

{title}

+ + {totalCount} + +
+
+ + + ${formatCurrency(totalValue)} + +
+
+ + {/* Cards Container */} +
+ {items.length === 0 ? ( +
+ {isDragOver ? 'Soltar aqui' : 'Sin oportunidades'} +
+ ) : ( + items.map((opportunity) => ( + + )) + )} +
+
+ ); +} + +export default KanbanColumn; diff --git a/src/shared/components/crm/index.ts b/src/shared/components/crm/index.ts new file mode 100644 index 0000000..fa1b249 --- /dev/null +++ b/src/shared/components/crm/index.ts @@ -0,0 +1,3 @@ +// CRM Components +export { KanbanCard, type KanbanOpportunity, type KanbanCardProps } from './KanbanCard'; +export { KanbanColumn, type StageKey, type KanbanColumnProps } from './KanbanColumn';