[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 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-20 04:32:20 -06:00
parent f1a9ea3d1f
commit 3a461cb184
26 changed files with 3483 additions and 32 deletions

View File

@ -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 <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>;
}
@ -230,11 +244,40 @@ export const router = createBrowserRouter([
</DashboardWrapper>
),
},
// CRM routes
{
path: '/crm',
element: <Navigate to="/crm/pipeline" replace />,
},
{
path: '/crm/pipeline',
element: (
<DashboardWrapper>
<PipelineKanbanPage />
</DashboardWrapper>
),
},
{
path: '/crm/leads',
element: (
<DashboardWrapper>
<LeadsPage />
</DashboardWrapper>
),
},
{
path: '/crm/opportunities',
element: (
<DashboardWrapper>
<OpportunitiesPage />
</DashboardWrapper>
),
},
{
path: '/crm/*',
element: (
<DashboardWrapper>
<div className="text-center text-gray-500">Módulo CRM - En desarrollo</div>
<div className="text-center text-gray-500">Seccion CRM - En desarrollo</div>
</DashboardWrapper>
),
},
@ -246,11 +289,68 @@ export const router = createBrowserRouter([
</DashboardWrapper>
),
},
// Settings routes
{
path: '/settings',
element: (
<DashboardWrapper>
<SettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/company',
element: (
<DashboardWrapper>
<CompanySettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/users',
element: (
<DashboardWrapper>
<UsersSettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/profile',
element: (
<DashboardWrapper>
<ProfileSettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/security',
element: (
<DashboardWrapper>
<SecuritySettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/security/audit-logs',
element: (
<DashboardWrapper>
<AuditLogsPage />
</DashboardWrapper>
),
},
{
path: '/settings/system',
element: (
<DashboardWrapper>
<SystemSettingsPage />
</DashboardWrapper>
),
},
{
path: '/settings/*',
element: (
<DashboardWrapper>
<div className="text-center text-gray-500">Configuración - En desarrollo</div>
<div className="text-center text-gray-500">Seccion de Configuracion - En desarrollo</div>
</DashboardWrapper>
),
},

View File

@ -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<LeadStatus | ''>('');
const [selectedSource, setSelectedSource] = useState<LeadSource | ''>('');
const [searchTerm, setSearchTerm] = useState('');
@ -86,7 +88,7 @@ export function LeadsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', lead.id),
onClick: () => navigate(`/crm/leads/${lead.id}`),
},
];

View File

@ -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<OpportunityStatus | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [oppToWin, setOppToWin] = useState<Opportunity | null>(null);
@ -69,7 +71,7 @@ export function OpportunitiesPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', opp.id),
onClick: () => navigate(`/crm/opportunities/${opp.id}`),
},
];

View File

@ -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<StageKey, string> = {
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<StageKey, KanbanOpportunity[]> => ({
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<DateRange>({ start: null, end: null });
const [selectedUser, setSelectedUser] = useState<string>('');
const [showFilters, setShowFilters] = useState(false);
// State for opportunities by stage
const [opportunitiesByStage, setOpportunitiesByStage] = useState<Record<StageKey, KanbanOpportunity[]>>(
generateMockOpportunities
);
// Filter opportunities based on search and filters
const filteredOpportunities = useMemo(() => {
const result: Record<StageKey, KanbanOpportunity[]> = {
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 (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex-shrink-0 space-y-4 p-6 pb-0">
<Breadcrumbs
items={[
{ label: 'CRM', href: '/crm' },
{ label: 'Pipeline' },
]}
/>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pipeline de Ventas</h1>
<p className="text-sm text-gray-500">
Gestiona oportunidades arrastrando tarjetas entre etapas
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="mr-2 h-4 w-4" />
Actualizar
</Button>
<Link to="/crm/opportunities">
<Button variant="outline">
<List className="mr-2 h-4 w-4" />
Vista Lista
</Button>
</Link>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nueva oportunidad
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Oportunidades</div>
<div className="text-xl font-bold text-gray-900">{totals.totalOpportunities}</div>
</div>
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Pipeline Total</div>
<div className="text-xl font-bold text-blue-600">${formatCurrency(totals.openValue)}</div>
</div>
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Ponderado</div>
<div className="text-xl font-bold text-amber-600">${formatCurrency(totals.weightedValue)}</div>
</div>
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Ganado</div>
<div className="text-xl font-bold text-green-600">${formatCurrency(totals.wonValue)}</div>
</div>
<div className="rounded-lg border bg-white p-3">
<div className="text-sm text-gray-500">Valor Total</div>
<div className="text-xl font-bold text-gray-900">${formatCurrency(totals.totalValue)}</div>
</div>
</div>
{/* Filters */}
<div className="rounded-lg border bg-white p-4">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar oportunidades..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
{/* Toggle Filters */}
<Button
variant={showFilters ? 'secondary' : 'outline'}
size="sm"
onClick={() => setShowFilters(!showFilters)}
>
<Filter className="mr-2 h-4 w-4" />
Filtros
{hasActiveFilters && (
<span className="ml-2 rounded-full bg-primary-600 px-1.5 py-0.5 text-xs text-white">
{[searchTerm, selectedUser, dateRange.start].filter(Boolean).length}
</span>
)}
</Button>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
Limpiar filtros
</Button>
)}
</div>
{/* Expanded Filters */}
{showFilters && (
<div className="mt-4 flex flex-wrap items-end gap-4 border-t pt-4">
<div className="min-w-[200px]">
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Responsable
</label>
<Select
options={mockUsers}
value={selectedUser}
onChange={(v) => setSelectedUser(v as string)}
placeholder="Todos los usuarios"
clearable
/>
</div>
<div className="min-w-[280px]">
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Fecha de cierre esperada
</label>
<DateRangePicker
value={dateRange}
onChange={setDateRange}
placeholder="Seleccionar rango"
/>
</div>
</div>
)}
</div>
</div>
{/* Kanban Board */}
<div className="flex-1 overflow-x-auto p-6">
<div className="flex gap-4 min-w-max">
{(Object.keys(stageLabels) as StageKey[]).map((stage) => (
<KanbanColumn
key={stage}
title={stageLabels[stage]}
stage={stage}
items={filteredOpportunities[stage]}
onDrop={handleDrop}
onCardClick={handleCardClick}
/>
))}
</div>
</div>
</div>
);
}
export default PipelineKanbanPage;

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
BookOpen,
Plus,
@ -45,6 +46,7 @@ const formatCurrency = (value: number): string => {
};
export function AccountsPage() {
const navigate = useNavigate();
const [selectedType, setSelectedType] = useState<AccountTypeEnum | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [showDeprecated, setShowDeprecated] = useState(false);
@ -82,19 +84,19 @@ export function AccountsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', account.id),
onClick: () => navigate(`/financial/accounts/${account.id}`),
},
{
key: 'edit',
label: 'Editar',
icon: <Edit className="h-4 w-4" />,
onClick: () => console.log('Edit', account.id),
onClick: () => navigate(`/financial/accounts/${account.id}/edit`),
},
{
key: 'children',
label: 'Ver subcuentas',
icon: <FolderTree className="h-4 w-4" />,
onClick: () => console.log('Children', account.id),
onClick: () => navigate(`/financial/accounts?parentId=${account.id}`),
},
{
key: 'delete',

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Receipt,
Plus,
@ -49,6 +50,7 @@ const formatCurrency = (value: number): string => {
};
export function InvoicesPage() {
const navigate = useNavigate();
const [selectedStatus, setSelectedStatus] = useState<FinancialInvoiceStatus | ''>('');
const [selectedType, setSelectedType] = useState<InvoiceType | ''>('');
const [searchTerm, setSearchTerm] = useState('');
@ -83,7 +85,7 @@ export function InvoicesPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', invoice.id),
onClick: () => navigate(`/financial/invoices/${invoice.id}`),
},
];
@ -108,7 +110,8 @@ export function InvoicesPage() {
key: 'payment',
label: 'Registrar pago',
icon: <CreditCard className="h-4 w-4" />,
onClick: () => console.log('Register payment', invoice.id),
// TODO: Implement payment modal - setShowPaymentModal(true)
onClick: () => navigate(`/financial/invoices/${invoice.id}/payment`),
});
items.push({
key: 'cancel',

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
FileText,
Plus,
@ -46,6 +47,7 @@ const getToday = (): string => {
};
export function JournalEntriesPage() {
const navigate = useNavigate();
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | ''>('');
const [selectedJournal, setSelectedJournal] = useState('');
const [searchTerm, setSearchTerm] = useState('');
@ -82,7 +84,7 @@ export function JournalEntriesPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', entry.id),
onClick: () => navigate(`/financial/entries/${entry.id}`),
},
];

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ClipboardList,
Plus,
@ -43,6 +44,7 @@ const statusColors: Record<CountStatus, string> = {
};
export function InventoryCountsPage() {
const navigate = useNavigate();
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<CountStatus | ''>('');
const [selectedType, setSelectedType] = useState<CountType | ''>('');
@ -76,7 +78,7 @@ export function InventoryCountsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', count.id),
onClick: () => navigate(`/inventory/counts/${count.id}`),
},
];

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowDownToLine,
ArrowUpFromLine,
@ -55,6 +56,7 @@ const statusColors: Record<MovementStatus, string> = {
};
export function MovementsPage() {
const navigate = useNavigate();
const [selectedType, setSelectedType] = useState<MovementType | ''>('');
const [selectedStatus, setSelectedStatus] = useState<MovementStatus | ''>('');
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
@ -89,7 +91,7 @@ export function MovementsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', movement.id),
onClick: () => navigate(`/inventory/movements/${movement.id}`),
},
];

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
AlertTriangle,
Package,
@ -40,6 +41,7 @@ const alertLevelLabels: Record<AlertLevel, string> = {
};
export function ReorderAlertsPage() {
const navigate = useNavigate();
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
const [selectedAlertLevel, setSelectedAlertLevel] = useState<AlertLevel | ''>('');
@ -82,19 +84,19 @@ export function ReorderAlertsPage() {
key: 'view',
label: 'Ver producto',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View product', alert.productId),
onClick: () => navigate(`/inventory/products/${alert.productId}`),
},
{
key: 'history',
label: 'Ver historial',
icon: <TrendingDown className="h-4 w-4" />,
onClick: () => console.log('View history', alert.productId),
onClick: () => navigate(`/inventory/products/${alert.productId}/history`),
},
{
key: 'order',
label: 'Crear orden de compra',
icon: <ShoppingCart className="h-4 w-4" />,
onClick: () => console.log('Create PO', alert.productId),
onClick: () => navigate(`/purchases/orders/new?productId=${alert.productId}`),
},
];

View File

@ -24,7 +24,10 @@ const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const getToday = (): string => getToday() || '';
const getToday = (): string => {
const isoDate = new Date().toISOString();
return isoDate.substring(0, 10);
};
export function ValuationReportsPage() {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
FolderKanban,
Plus,
@ -42,6 +43,7 @@ const statusColors: Record<ProjectStatus, string> = {
};
export function ProjectsPage() {
const navigate = useNavigate();
const [selectedStatus, setSelectedStatus] = useState<ProjectStatus | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [projectToActivate, setProjectToActivate] = useState<Project | null>(null);
@ -72,7 +74,7 @@ export function ProjectsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', project.id),
onClick: () => navigate(`/projects/${project.id}`),
},
];

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ListTodo,
Plus,
@ -57,6 +58,7 @@ const priorityColors: Record<TaskPriority, string> = {
};
export function TasksPage() {
const navigate = useNavigate();
const [selectedStatus, setSelectedStatus] = useState<TaskStatus | ''>('');
const [selectedPriority, setSelectedPriority] = useState<TaskPriority | ''>('');
const [searchTerm, setSearchTerm] = useState('');
@ -89,7 +91,7 @@ export function TasksPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', task.id),
onClick: () => navigate(`/projects/tasks/${task.id}`),
},
];

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ShoppingBag,
Plus,
@ -47,6 +48,7 @@ const formatCurrency = (value: number): string => {
};
export function PurchaseOrdersPage() {
const navigate = useNavigate();
const [selectedStatus, setSelectedStatus] = useState<PurchaseOrderStatus | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [dateFrom, setDateFrom] = useState('');
@ -80,7 +82,7 @@ export function PurchaseOrdersPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', order.id),
onClick: () => navigate(`/purchases/orders/${order.id}`),
},
{
key: 'pdf',
@ -111,13 +113,15 @@ export function PurchaseOrdersPage() {
key: 'receive',
label: 'Recibir productos',
icon: <Package className="h-4 w-4" />,
onClick: () => console.log('Receive', order.id),
// TODO: Implement receive modal - setShowReceiveModal(true)
onClick: () => navigate(`/purchases/orders/${order.id}/receive`),
});
items.push({
key: 'invoice',
label: 'Crear factura',
icon: <FileText className="h-4 w-4" />,
onClick: () => console.log('Invoice', order.id),
// TODO: Implement invoice modal - setShowInvoiceModal(true)
onClick: () => navigate(`/purchases/orders/${order.id}/invoice`),
});
}

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Package,
Plus,
@ -44,6 +45,7 @@ const getToday = (): string => {
};
export function PurchaseReceiptsPage() {
const navigate = useNavigate();
const [selectedStatus, setSelectedStatus] = useState<ReceiptStatus | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [dateFrom, setDateFrom] = useState('');
@ -76,7 +78,7 @@ export function PurchaseReceiptsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', receipt.id),
onClick: () => navigate(`/purchases/receipts/${receipt.id}`),
},
];

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
FileText,
Plus,
@ -62,6 +63,7 @@ const typeColors: Record<ReportType, string> = {
};
export function ReportsPage() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabType>('definitions');
const [searchTerm, setSearchTerm] = useState('');
const [selectedType, setSelectedType] = useState<ReportType | ''>('');
@ -189,13 +191,14 @@ export function ReportsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', def.id),
onClick: () => navigate(`/reports/definitions/${def.id}`),
},
{
key: 'execute',
label: 'Ejecutar',
icon: <Play className="h-4 w-4" />,
onClick: () => console.log('Execute', def.id),
// TODO: Keep for now - opens report execution modal/flow
onClick: () => navigate(`/reports/definitions/${def.id}/execute`),
},
];
@ -205,7 +208,7 @@ export function ReportsPage() {
key: 'edit',
label: 'Editar',
icon: <Edit2 className="h-4 w-4" />,
onClick: () => console.log('Edit', def.id),
onClick: () => navigate(`/reports/definitions/${def.id}/edit`),
},
{
key: 'toggle',
@ -321,7 +324,7 @@ export function ReportsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', exec.id),
onClick: () => navigate(`/reports/executions/${exec.id}`),
},
];
@ -425,13 +428,13 @@ export function ReportsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', sched.id),
onClick: () => navigate(`/reports/schedules/${sched.id}`),
},
{
key: 'edit',
label: 'Editar',
icon: <Edit2 className="h-4 w-4" />,
onClick: () => console.log('Edit', sched.id),
onClick: () => navigate(`/reports/schedules/${sched.id}/edit`),
},
{
key: 'runNow',

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
FileText,
Plus,
@ -52,6 +53,7 @@ const formatCurrency = (value: number): string => {
};
export function QuotationsPage() {
const navigate = useNavigate();
const [selectedStatus, setSelectedStatus] = useState<QuotationStatus | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [dateFrom, setDateFrom] = useState('');
@ -89,7 +91,7 @@ export function QuotationsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', quotation.id),
onClick: () => navigate(`/sales/quotations/${quotation.id}`),
},
{
key: 'download',

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ShoppingCart,
Plus,
@ -70,6 +71,7 @@ const formatCurrency = (value: number): string => {
};
export function SalesOrdersPage() {
const navigate = useNavigate();
const [selectedStatus, setSelectedStatus] = useState<SalesOrderStatus | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [dateFrom, setDateFrom] = useState('');
@ -104,7 +106,7 @@ export function SalesOrdersPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', order.id),
onClick: () => navigate(`/sales/orders/${order.id}`),
},
];

View File

@ -0,0 +1,472 @@
import { useState } from 'react';
import {
Building2,
Upload,
Save,
Phone,
Mail,
Globe,
MapPin,
Hash,
DollarSign,
FileText,
} 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 CompanyFormData {
// General info
name: string;
legalName: string;
logo: string | null;
// Contact info
phone: string;
email: string;
website: string;
// Address
street: string;
city: string;
state: string;
postalCode: string;
country: string;
// Fiscal info
taxId: string;
fiscalRegime: string;
fiscalAddress: string;
// Settings
currency: string;
numberFormat: string;
dateFormat: string;
}
const initialFormData: CompanyFormData = {
name: 'Mi Empresa S.A. de C.V.',
legalName: 'Mi Empresa Sociedad Anonima de Capital Variable',
logo: null,
phone: '+52 55 1234 5678',
email: 'contacto@miempresa.com',
website: 'https://miempresa.com',
street: 'Av. Reforma 123, Col. Centro',
city: 'Ciudad de Mexico',
state: 'CDMX',
postalCode: '06000',
country: 'Mexico',
taxId: 'XAXX010101000',
fiscalRegime: '601',
fiscalAddress: 'Av. Reforma 123, Col. Centro, CDMX, 06000',
currency: 'MXN',
numberFormat: '1,234.56',
dateFormat: 'DD/MM/YYYY',
};
const fiscalRegimes = [
{ value: '601', label: '601 - General de Ley Personas Morales' },
{ value: '603', label: '603 - Personas Morales con Fines no Lucrativos' },
{ value: '605', label: '605 - Sueldos y Salarios e Ingresos Asimilados a Salarios' },
{ value: '606', label: '606 - Arrendamiento' },
{ value: '607', label: '607 - Regimen de Enajenacion o Adquisicion de Bienes' },
{ value: '608', label: '608 - Demas ingresos' },
{ value: '610', label: '610 - Residentes en el Extranjero sin Establecimiento Permanente en Mexico' },
{ value: '611', label: '611 - Ingresos por Dividendos (socios y accionistas)' },
{ value: '612', label: '612 - Personas Fisicas con Actividades Empresariales y Profesionales' },
{ value: '614', label: '614 - Ingresos por intereses' },
{ value: '615', label: '615 - Regimen de los ingresos por obtencion de premios' },
{ value: '616', label: '616 - Sin obligaciones fiscales' },
{ value: '620', label: '620 - Sociedades Cooperativas de Produccion que optan por diferir sus ingresos' },
{ value: '621', label: '621 - Incorporacion Fiscal' },
{ value: '622', label: '622 - Actividades Agricolas, Ganaderas, Silvicolas y Pesqueras' },
{ value: '623', label: '623 - Opcional para Grupos de Sociedades' },
{ value: '624', label: '624 - Coordinados' },
{ value: '625', label: '625 - Regimen de las Actividades Empresariales con ingresos a traves de Plataformas Tecnologicas' },
{ value: '626', label: '626 - Regimen Simplificado de Confianza' },
];
const currencies = [
{ value: 'MXN', label: 'MXN - Peso Mexicano' },
{ value: 'USD', label: 'USD - Dolar Estadounidense' },
{ value: 'EUR', label: 'EUR - Euro' },
{ value: 'CAD', label: 'CAD - Dolar Canadiense' },
];
const numberFormats = [
{ value: '1,234.56', label: '1,234.56 (Coma para miles, punto decimal)' },
{ value: '1.234,56', label: '1.234,56 (Punto para miles, coma decimal)' },
{ value: '1 234.56', label: '1 234.56 (Espacio para miles, punto decimal)' },
];
const dateFormats = [
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY (31/12/2026)' },
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY (12/31/2026)' },
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD (2026-12-31)' },
];
export function CompanySettingsPage() {
const [formData, setFormData] = useState<CompanyFormData>(initialFormData);
const [isSaving, setIsSaving] = useState(false);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const handleInputChange = (field: keyof CompanyFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleLogoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSave = async () => {
setIsSaving(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsSaving(false);
alert('Configuracion guardada correctamente');
};
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Configuracion', href: '/settings' },
{ label: 'Empresa' },
]}
/>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Configuracion de Empresa</h1>
<p className="text-sm text-gray-500">
Administra la informacion general de tu empresa
</p>
</div>
<Button onClick={handleSave} isLoading={isSaving}>
<Save className="mr-2 h-4 w-4" />
Guardar cambios
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* General Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-blue-600" />
Informacion General
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Logo */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Logo de la empresa
</label>
<div className="flex items-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
className="h-full w-full object-contain rounded-lg"
/>
) : (
<Building2 className="h-8 w-8 text-gray-400" />
)}
</div>
<div>
<label className="cursor-pointer">
<span className="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
<Upload className="h-4 w-4" />
Subir logo
</span>
<input
type="file"
accept="image/*"
onChange={handleLogoChange}
className="hidden"
/>
</label>
<p className="mt-1 text-xs text-gray-500">
PNG, JPG o SVG. Maximo 2MB.
</p>
</div>
</div>
</div>
{/* Company Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre comercial
</label>
<Input
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="Nombre de la empresa"
/>
</div>
{/* Legal Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Razon social
</label>
<Input
value={formData.legalName}
onChange={(e) => handleInputChange('legalName', e.target.value)}
placeholder="Razon social completa"
/>
</div>
</CardContent>
</Card>
{/* Contact Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5 text-green-600" />
Informacion de Contacto
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Phone className="inline-block h-4 w-4 mr-1" />
Telefono
</label>
<Input
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="+52 55 1234 5678"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Mail className="inline-block h-4 w-4 mr-1" />
Correo electronico
</label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="contacto@empresa.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Globe className="inline-block h-4 w-4 mr-1" />
Sitio web
</label>
<Input
type="url"
value={formData.website}
onChange={(e) => handleInputChange('website', e.target.value)}
placeholder="https://www.empresa.com"
/>
</div>
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-red-600" />
Direccion
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Calle y numero
</label>
<Input
value={formData.street}
onChange={(e) => handleInputChange('street', e.target.value)}
placeholder="Av. Reforma 123, Col. Centro"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ciudad
</label>
<Input
value={formData.city}
onChange={(e) => handleInputChange('city', e.target.value)}
placeholder="Ciudad de Mexico"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estado
</label>
<Input
value={formData.state}
onChange={(e) => handleInputChange('state', e.target.value)}
placeholder="CDMX"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Codigo postal
</label>
<Input
value={formData.postalCode}
onChange={(e) => handleInputChange('postalCode', e.target.value)}
placeholder="06000"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Pais
</label>
<Input
value={formData.country}
onChange={(e) => handleInputChange('country', e.target.value)}
placeholder="Mexico"
/>
</div>
</div>
</CardContent>
</Card>
{/* Fiscal Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-purple-600" />
Informacion Fiscal
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Hash className="inline-block h-4 w-4 mr-1" />
RFC / Tax ID
</label>
<Input
value={formData.taxId}
onChange={(e) => handleInputChange('taxId', e.target.value.toUpperCase())}
placeholder="XAXX010101000"
className="uppercase"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Regimen fiscal
</label>
<select
value={formData.fiscalRegime}
onChange={(e) => handleInputChange('fiscalRegime', e.target.value)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{fiscalRegimes.map((regime) => (
<option key={regime.value} value={regime.value}>
{regime.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Domicilio fiscal
</label>
<Input
value={formData.fiscalAddress}
onChange={(e) => handleInputChange('fiscalAddress', e.target.value)}
placeholder="Domicilio fiscal completo"
/>
</div>
</CardContent>
</Card>
{/* Regional Settings */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-yellow-600" />
Configuracion Regional
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Moneda predeterminada
</label>
<select
value={formData.currency}
onChange={(e) => handleInputChange('currency', e.target.value)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{currencies.map((currency) => (
<option key={currency.value} value={currency.value}>
{currency.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Formato de numeros
</label>
<select
value={formData.numberFormat}
onChange={(e) => handleInputChange('numberFormat', e.target.value)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{numberFormats.map((format) => (
<option key={format.value} value={format.value}>
{format.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Formato de fecha
</label>
<select
value={formData.dateFormat}
onChange={(e) => handleInputChange('dateFormat', e.target.value)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{dateFormats.map((format) => (
<option key={format.value} value={format.value}>
{format.label}
</option>
))}
</select>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end">
<Button onClick={handleSave} isLoading={isSaving}>
<Save className="mr-2 h-4 w-4" />
Guardar cambios
</Button>
</CardFooter>
</Card>
</div>
</div>
);
}
export default CompanySettingsPage;

View File

@ -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 (
<div className="flex items-center justify-between py-3">
<div>
<div className="text-sm font-medium text-gray-900">{label}</div>
{description && <div className="text-sm text-gray-500">{description}</div>}
</div>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
checked ? 'bg-primary-600' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
);
}
export function ProfileSettingsPage() {
const [profileData, setProfileData] = useState<ProfileFormData>(initialProfileData);
const [passwordData, setPasswordData] = useState<PasswordFormData>(initialPasswordData);
const [notifications, setNotifications] = useState<NotificationSettings>(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<string | null>(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<HTMLInputElement>) => {
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 (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Configuracion', href: '/settings' },
{ label: 'Mi perfil' },
]}
/>
<div>
<h1 className="text-2xl font-bold text-gray-900">Mi Perfil</h1>
<p className="text-sm text-gray-500">
Administra tu informacion personal y preferencias
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Profile Information */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5 text-purple-600" />
Informacion Personal
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row gap-6">
{/* Avatar */}
<div className="flex flex-col items-center gap-3">
<div className="relative">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-primary-100 text-2xl font-bold text-primary-600 overflow-hidden">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
className="h-full w-full object-cover"
/>
) : (
initials
)}
</div>
<label className="absolute bottom-0 right-0 cursor-pointer">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-600 text-white shadow-lg hover:bg-primary-700">
<Camera className="h-4 w-4" />
</span>
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
</label>
</div>
<p className="text-xs text-gray-500">Cambiar foto</p>
</div>
{/* Form Fields */}
<div className="flex-1 grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre
</label>
<Input
value={profileData.firstName}
onChange={(e) => handleProfileChange('firstName', e.target.value)}
placeholder="Tu nombre"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Apellido
</label>
<Input
value={profileData.lastName}
onChange={(e) => handleProfileChange('lastName', e.target.value)}
placeholder="Tu apellido"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Mail className="inline-block h-4 w-4 mr-1" />
Correo electronico
</label>
<Input
type="email"
value={profileData.email}
onChange={(e) => handleProfileChange('email', e.target.value)}
placeholder="tu@email.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Phone className="inline-block h-4 w-4 mr-1" />
Telefono
</label>
<Input
type="tel"
value={profileData.phone}
onChange={(e) => handleProfileChange('phone', e.target.value)}
placeholder="+52 55 1234 5678"
/>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end">
<Button onClick={handleSaveProfile} isLoading={isSavingProfile}>
<Save className="mr-2 h-4 w-4" />
Guardar perfil
</Button>
</CardFooter>
</Card>
{/* Change Password */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="h-5 w-5 text-red-600" />
Cambiar Contrasena
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Contrasena actual
</label>
<div className="relative">
<Input
type={showCurrentPassword ? 'text' : 'password'}
value={passwordData.currentPassword}
onChange={(e) => handlePasswordChange('currentPassword', e.target.value)}
placeholder="Tu contrasena actual"
/>
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showCurrentPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nueva contrasena
</label>
<div className="relative">
<Input
type={showNewPassword ? 'text' : 'password'}
value={passwordData.newPassword}
onChange={(e) => handlePasswordChange('newPassword', e.target.value)}
placeholder="Minimo 8 caracteres"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showNewPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirmar nueva contrasena
</label>
<div className="relative">
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={passwordData.confirmPassword}
onChange={(e) => handlePasswordChange('confirmPassword', e.target.value)}
placeholder="Repite la nueva contrasena"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{passwordData.newPassword &&
passwordData.confirmPassword &&
passwordData.newPassword !== passwordData.confirmPassword && (
<p className="mt-1 text-sm text-red-600">
Las contrasenas no coinciden
</p>
)}
{passwordData.newPassword &&
passwordData.confirmPassword &&
passwordData.newPassword === passwordData.confirmPassword && (
<p className="mt-1 text-sm text-green-600 flex items-center gap-1">
<Check className="h-4 w-4" />
Las contrasenas coinciden
</p>
)}
</div>
</CardContent>
<CardFooter className="flex justify-end">
<Button
onClick={handleSavePassword}
isLoading={isSavingPassword}
disabled={
!passwordData.currentPassword ||
!passwordData.newPassword ||
!passwordData.confirmPassword ||
passwordData.newPassword !== passwordData.confirmPassword
}
>
<Lock className="mr-2 h-4 w-4" />
Actualizar contrasena
</Button>
</CardFooter>
</Card>
{/* Locale Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-600" />
Idioma y Region
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Idioma
</label>
<select
value={profileData.language}
onChange={(e) => handleProfileChange('language', e.target.value)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Zona horaria
</label>
<select
value={profileData.timezone}
onChange={(e) => handleProfileChange('timezone', e.target.value)}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{timezones.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
</div>
</CardContent>
<CardFooter className="flex justify-end">
<Button onClick={handleSaveProfile} isLoading={isSavingProfile}>
<Save className="mr-2 h-4 w-4" />
Guardar preferencias
</Button>
</CardFooter>
</Card>
{/* Notification Settings */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5 text-yellow-600" />
Notificaciones
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x">
<div className="space-y-1">
<h4 className="font-medium text-gray-900 mb-3">Canales de notificacion</h4>
<ToggleSwitch
checked={notifications.emailNotifications}
onChange={(value) => handleNotificationChange('emailNotifications', value)}
label="Notificaciones por email"
description="Recibe alertas y actualizaciones por correo"
/>
<ToggleSwitch
checked={notifications.pushNotifications}
onChange={(value) => handleNotificationChange('pushNotifications', value)}
label="Notificaciones push"
description="Notificaciones en el navegador"
/>
<ToggleSwitch
checked={notifications.smsNotifications}
onChange={(value) => handleNotificationChange('smsNotifications', value)}
label="Notificaciones SMS"
description="Recibe alertas por mensaje de texto"
/>
</div>
<div className="pt-4 sm:pt-0 sm:pl-4 space-y-1">
<h4 className="font-medium text-gray-900 mb-3">Tipos de notificacion</h4>
<ToggleSwitch
checked={notifications.securityAlerts}
onChange={(value) => handleNotificationChange('securityAlerts', value)}
label="Alertas de seguridad"
description="Inicios de sesion y cambios de contrasena"
/>
<ToggleSwitch
checked={notifications.weeklyReport}
onChange={(value) => handleNotificationChange('weeklyReport', value)}
label="Reporte semanal"
description="Resumen de actividad semanal"
/>
<ToggleSwitch
checked={notifications.monthlyReport}
onChange={(value) => handleNotificationChange('monthlyReport', value)}
label="Reporte mensual"
description="Resumen de actividad mensual"
/>
<ToggleSwitch
checked={notifications.marketingEmails}
onChange={(value) => handleNotificationChange('marketingEmails', value)}
label="Emails de marketing"
description="Novedades, ofertas y actualizaciones"
/>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end">
<Button onClick={handleSaveNotifications} isLoading={isSavingNotifications}>
<Save className="mr-2 h-4 w-4" />
Guardar notificaciones
</Button>
</CardFooter>
</Card>
</div>
</div>
);
}
export default ProfileSettingsPage;

View File

@ -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<Session[]>(mockSessions);
const [loginHistory] = useState<LoginHistory[]>(mockLoginHistory);
const [authorizedDevices, setAuthorizedDevices] = useState<AuthorizedDevice[]>(mockAuthorizedDevices);
const [sessionToRevoke, setSessionToRevoke] = useState<Session | null>(null);
const [deviceToRemove, setDeviceToRemove] = useState<AuthorizedDevice | null>(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 <Monitor className="h-5 w-5" />;
case 'mobile':
return <Smartphone className="h-5 w-5" />;
case 'tablet':
return <Monitor className="h-5 w-5" />;
default:
return <Monitor className="h-5 w-5" />;
}
};
const sessionColumns: Column<Session>[] = [
{
key: 'device',
header: 'Dispositivo',
render: (session) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
<Monitor className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="font-medium text-gray-900 flex items-center gap-2">
{session.device}
{session.isCurrent && (
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
Actual
</span>
)}
</div>
<div className="text-sm text-gray-500">{session.browser}</div>
</div>
</div>
),
},
{
key: 'location',
header: 'Ubicacion',
render: (session) => (
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-gray-400" />
<div>
<div className="text-sm text-gray-900">{session.location}</div>
<div className="text-xs text-gray-500 font-mono">{session.ipAddress}</div>
</div>
</div>
),
},
{
key: 'lastActive',
header: 'Ultima actividad',
render: (session) => (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Clock className="h-4 w-4" />
{session.isCurrent ? 'Ahora' : formatDate(session.lastActive, 'short')}
</div>
),
},
{
key: 'actions',
header: '',
render: (session) =>
!session.isCurrent && (
<Button
variant="ghost"
size="sm"
onClick={() => setSessionToRevoke(session)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<LogOut className="h-4 w-4" />
</Button>
),
},
];
const loginHistoryColumns: Column<LoginHistory>[] = [
{
key: 'date',
header: 'Fecha',
render: (log) => (
<span className="text-sm text-gray-900">
{formatDate(log.date, 'full')}
</span>
),
},
{
key: 'status',
header: 'Estado',
render: (log) => (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
log.status === 'success'
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{log.status === 'success' ? (
<>
<Check className="h-3 w-3" />
Exitoso
</>
) : (
<>
<X className="h-3 w-3" />
Fallido
</>
)}
</span>
),
},
{
key: 'location',
header: 'Ubicacion',
render: (log) => (
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600">{log.location}</span>
</div>
),
},
{
key: 'device',
header: 'Dispositivo',
render: (log) => (
<div className="text-sm text-gray-600">
{log.device} / {log.browser}
</div>
),
},
{
key: 'ipAddress',
header: 'IP',
render: (log) => (
<span className="text-sm text-gray-500 font-mono">{log.ipAddress}</span>
),
},
];
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Configuracion', href: '/settings' },
{ label: 'Seguridad' },
]}
/>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Seguridad</h1>
<p className="text-sm text-gray-500">
Administra la seguridad de tu cuenta
</p>
</div>
<Button variant="outline" onClick={() => window.location.href = '/settings/security/audit-logs'}>
Ver logs de auditoria
</Button>
</div>
{/* Two-Factor Authentication */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5 text-purple-600" />
Autenticacion de Dos Factores (2FA)
</CardTitle>
</CardHeader>
<CardContent>
{!show2FASetup ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full ${
is2FAEnabled ? 'bg-green-100' : 'bg-gray-100'
}`}
>
<Shield
className={`h-6 w-6 ${is2FAEnabled ? 'text-green-600' : 'text-gray-400'}`}
/>
</div>
<div>
<div className="font-medium text-gray-900">
{is2FAEnabled
? 'Autenticacion de dos factores activada'
: 'Autenticacion de dos factores desactivada'}
</div>
<div className="text-sm text-gray-500">
{is2FAEnabled
? 'Tu cuenta esta protegida con un segundo factor de autenticacion'
: 'Anade una capa extra de seguridad a tu cuenta'}
</div>
</div>
</div>
<Button
variant={is2FAEnabled ? 'outline' : 'primary'}
onClick={is2FAEnabled ? handleDisable2FA : handleEnable2FA}
isLoading={isLoading}
>
{is2FAEnabled ? 'Desactivar' : 'Activar 2FA'}
</Button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-start gap-4 p-4 bg-yellow-50 rounded-lg">
<AlertTriangle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-800">
<p className="font-medium">Configura tu aplicacion de autenticacion</p>
<p className="mt-1">
Escanea el codigo QR con tu aplicacion de autenticacion (Google Authenticator, Authy, etc.)
o ingresa el codigo secreto manualmente.
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-6 items-center">
{/* QR Code placeholder */}
<div className="flex h-48 w-48 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50">
<div className="text-center">
<QrCode className="h-16 w-16 text-gray-400 mx-auto" />
<p className="mt-2 text-xs text-gray-500">Codigo QR</p>
</div>
</div>
<div className="flex-1 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Codigo secreto (si no puedes escanear)
</label>
<div className="flex gap-2">
<code className="flex-1 rounded-md bg-gray-100 px-4 py-2 font-mono text-sm">
{mock2FASecret}
</code>
<Button variant="outline" size="sm" onClick={handleCopySecret}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Codigo de verificacion
</label>
<div className="flex gap-2">
<Input
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
className="font-mono text-center text-lg tracking-widest"
maxLength={6}
/>
<Button onClick={handleVerify2FA} isLoading={isLoading}>
Verificar
</Button>
</div>
<p className="mt-1 text-xs text-gray-500">
Ingresa el codigo de 6 digitos de tu aplicacion
</p>
</div>
</div>
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={() => setShow2FASetup(false)}>
Cancelar
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Active Sessions */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-blue-600" />
Sesiones Activas
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={handleRevokeAllSessions}
className="text-red-600 border-red-300 hover:bg-red-50"
>
<LogOut className="mr-2 h-4 w-4" />
Cerrar todas las sesiones
</Button>
</div>
</CardHeader>
<CardContent>
<DataTable
data={sessions}
columns={sessionColumns}
isLoading={false}
/>
</CardContent>
</Card>
{/* Login History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5 text-green-600" />
Historial de Inicios de Sesion
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-500 mb-4">
Ultimos 10 intentos de inicio de sesion
</p>
<DataTable
data={loginHistory}
columns={loginHistoryColumns}
isLoading={false}
/>
</CardContent>
</Card>
{/* Authorized Devices */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="h-5 w-5 text-orange-600" />
Dispositivos Autorizados
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{authorizedDevices.map((device) => (
<div
key={device.id}
className="flex items-center justify-between p-4 rounded-lg border bg-gray-50"
>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-white border">
{getDeviceIcon(device.type)}
</div>
<div>
<div className="font-medium text-gray-900 flex items-center gap-2">
{device.name}
{device.trusted && (
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
<Check className="h-3 w-3 mr-1" />
Confiable
</span>
)}
</div>
<div className="text-sm text-gray-500">
Ultimo uso: {formatDate(device.lastUsed, 'short')}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setDeviceToRemove(device)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
{/* Revoke Session Modal */}
<ConfirmModal
isOpen={!!sessionToRevoke}
onClose={() => 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 */}
<ConfirmModal
isOpen={!!deviceToRemove}
onClose={() => 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"
/>
</div>
);
}
export default SecuritySettingsPage;

View File

@ -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<BackupConfig>(initialBackupConfig);
const [systemLogs] = useState<SystemLog[]>(mockSystemLogs);
const [apiKeys, setApiKeys] = useState<ApiKey[]>(mockApiKeys);
const [modules, setModules] = useState<Module[]>(mockModules);
const [isBackingUp, setIsBackingUp] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
const [showNewApiKeyModal, setShowNewApiKeyModal] = useState(false);
const [newApiKeyName, setNewApiKeyName] = useState('');
const [generatedApiKey, setGeneratedApiKey] = useState<string | null>(null);
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKey | null>(null);
const [logLevelFilter, setLogLevelFilter] = useState<string>('');
const [logSourceFilter, setLogSourceFilter] = useState<string>('');
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 <Activity className="h-4 w-4 text-blue-600" />;
case 'warning':
return <AlertTriangle className="h-4 w-4 text-yellow-600" />;
case 'error':
return <X className="h-4 w-4 text-red-600" />;
}
};
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<SystemLog>[] = [
{
key: 'timestamp',
header: 'Fecha',
render: (log) => (
<span className="text-sm text-gray-600 whitespace-nowrap">
{formatDate(log.timestamp, 'full')}
</span>
),
},
{
key: 'level',
header: 'Nivel',
render: (log) => (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${getLevelColor(
log.level
)}`}
>
{getLevelIcon(log.level)}
{log.level.toUpperCase()}
</span>
),
},
{
key: 'source',
header: 'Fuente',
render: (log) => (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">
{log.source}
</span>
),
},
{
key: 'message',
header: 'Mensaje',
render: (log) => <span className="text-sm text-gray-900">{log.message}</span>,
},
];
const apiKeyColumns: Column<ApiKey>[] = [
{
key: 'name',
header: 'Nombre',
render: (apiKey) => (
<div>
<div className="font-medium text-gray-900">{apiKey.name}</div>
<div className="text-xs text-gray-500">
Creada: {formatDate(apiKey.createdAt, 'short')}
</div>
</div>
),
},
{
key: 'key',
header: 'API Key',
render: (apiKey) => (
<div className="flex items-center gap-2">
<code className="rounded bg-gray-100 px-2 py-1 font-mono text-xs">
{visibleKeys.has(apiKey.id)
? apiKey.key
: `${apiKey.key.slice(0, 10)}${'*'.repeat(20)}`}
</code>
<button
onClick={() => toggleKeyVisibility(apiKey.id)}
className="text-gray-400 hover:text-gray-600"
>
{visibleKeys.has(apiKey.id) ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
<button
onClick={() => handleCopyApiKey(apiKey.key)}
className="text-gray-400 hover:text-gray-600"
>
<Copy className="h-4 w-4" />
</button>
</div>
),
},
{
key: 'lastUsed',
header: 'Ultimo uso',
render: (apiKey) => (
<span className="text-sm text-gray-600">
{apiKey.lastUsed ? formatDate(apiKey.lastUsed, 'short') : 'Nunca'}
</span>
),
},
{
key: 'isActive',
header: 'Estado',
render: (apiKey) => (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
apiKey.isActive
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}
>
{apiKey.isActive ? (
<>
<Check className="h-3 w-3" />
Activa
</>
) : (
<>
<X className="h-3 w-3" />
Inactiva
</>
)}
</span>
),
},
{
key: 'actions',
header: '',
render: (apiKey) => (
<Button
variant="ghost"
size="sm"
onClick={() => setApiKeyToDelete(apiKey)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
),
},
];
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Configuracion', href: '/settings' },
{ label: 'Sistema' },
]}
/>
<div>
<h1 className="text-2xl font-bold text-gray-900">Configuracion del Sistema</h1>
<p className="text-sm text-gray-500">
Administra respaldos, logs, API keys y modulos
</p>
</div>
{/* System Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<Database className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Base de datos</div>
<div className="text-lg font-bold text-blue-600">2.3 GB</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<HardDrive className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Almacenamiento</div>
<div className="text-lg font-bold text-green-600">75%</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<Cpu className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-gray-500">CPU</div>
<div className="text-lg font-bold text-purple-600">23%</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100">
<Activity className="h-5 w-5 text-orange-600" />
</div>
<div>
<div className="text-sm text-gray-500">Uptime</div>
<div className="text-lg font-bold text-orange-600">99.9%</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Backup Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-blue-600" />
Respaldos
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">Respaldo automatico</div>
<div className="text-sm text-gray-500">
Realiza respaldos automaticos de la base de datos
</div>
</div>
<button
type="button"
role="switch"
aria-checked={backupConfig.autoBackup}
onClick={() =>
handleBackupConfigChange('autoBackup', !backupConfig.autoBackup)
}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
backupConfig.autoBackup ? 'bg-primary-600' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
backupConfig.autoBackup ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{backupConfig.autoBackup && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Frecuencia
</label>
<select
value={backupConfig.frequency}
onChange={(e) =>
handleBackupConfigChange('frequency', e.target.value)
}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option value="daily">Diario</option>
<option value="weekly">Semanal</option>
<option value="monthly">Mensual</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Retencion (dias)
</label>
<Input
type="number"
value={backupConfig.retentionDays}
onChange={(e) =>
handleBackupConfigChange('retentionDays', parseInt(e.target.value))
}
min={7}
max={365}
/>
</div>
</>
)}
</div>
<div className="space-y-4 lg:border-l lg:pl-6">
<div className="flex items-center gap-4 p-4 rounded-lg bg-gray-50">
<Clock className="h-8 w-8 text-gray-400" />
<div>
<div className="text-sm text-gray-500">Ultimo respaldo</div>
<div className="font-medium text-gray-900">
{backupConfig.lastBackup
? formatDate(backupConfig.lastBackup, 'full')
: 'Nunca'}
</div>
</div>
</div>
{backupConfig.autoBackup && backupConfig.nextBackup && (
<div className="flex items-center gap-4 p-4 rounded-lg bg-blue-50">
<Calendar className="h-8 w-8 text-blue-400" />
<div>
<div className="text-sm text-blue-600">Proximo respaldo</div>
<div className="font-medium text-blue-900">
{formatDate(backupConfig.nextBackup, 'full')}
</div>
</div>
</div>
)}
<div className="flex gap-2">
<Button onClick={handleBackupNow} isLoading={isBackingUp}>
<Download className="mr-2 h-4 w-4" />
Respaldar ahora
</Button>
<Button variant="outline" onClick={handleRestore} isLoading={isRestoring}>
<Upload className="mr-2 h-4 w-4" />
Restaurar
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* System Logs */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-green-600" />
Logs del Sistema
</CardTitle>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Exportar logs
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4 p-4 bg-gray-50 rounded-lg">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
<Filter className="inline-block h-3 w-3 mr-1" />
Nivel
</label>
<select
value={logLevelFilter}
onChange={(e) => setLogLevelFilter(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option value="">Todos</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Fuente
</label>
<select
value={logSourceFilter}
onChange={(e) => setLogSourceFilter(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
>
<option value="">Todas</option>
{uniqueSources.map((source) => (
<option key={source} value={source}>
{source}
</option>
))}
</select>
</div>
{(logLevelFilter || logSourceFilter) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setLogLevelFilter('');
setLogSourceFilter('');
}}
>
Limpiar filtros
</Button>
)}
</div>
<DataTable data={filteredLogs} columns={logColumns} isLoading={false} />
</div>
</CardContent>
</Card>
{/* API Keys */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5 text-purple-600" />
API Keys
</CardTitle>
<Button onClick={() => setShowNewApiKeyModal(true)}>
<Plus className="mr-2 h-4 w-4" />
Nueva API Key
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-500 mb-4">
Gestiona las API keys para integraciones externas
</p>
<DataTable data={apiKeys} columns={apiKeyColumns} isLoading={false} />
</CardContent>
</Card>
{/* Active Modules */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-orange-600" />
Modulos Activos
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{modules.map((module) => (
<div
key={module.id}
className={`rounded-lg border p-4 transition-colors ${
module.isActive ? 'border-green-200 bg-green-50' : 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-start justify-between">
<div>
<div className="font-medium text-gray-900 flex items-center gap-2">
{module.name}
{module.isCore && (
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
Core
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">v{module.version}</div>
<div className="text-sm text-gray-600 mt-2">{module.description}</div>
</div>
{!module.isCore && (
<button
onClick={() => handleToggleModule(module.id)}
className="text-gray-400 hover:text-gray-600"
>
{module.isActive ? (
<ToggleRight className="h-6 w-6 text-green-600" />
) : (
<ToggleLeft className="h-6 w-6 text-gray-400" />
)}
</button>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* New API Key Modal */}
{showNewApiKeyModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
{generatedApiKey ? 'API Key Generada' : 'Nueva API Key'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!generatedApiKey ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre de la API Key
</label>
<Input
value={newApiKeyName}
onChange={(e) => setNewApiKeyName(e.target.value)}
placeholder="Ej: Integracion Shopify"
/>
<p className="mt-1 text-xs text-gray-500">
Un nombre descriptivo para identificar esta API key
</p>
</div>
) : (
<div>
<div className="flex items-start gap-3 p-4 bg-yellow-50 rounded-lg mb-4">
<AlertTriangle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-800">
<p className="font-medium">Guarda esta API key de forma segura</p>
<p className="mt-1">
Esta es la unica vez que veras esta API key completa. No podras verla de nuevo.
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tu nueva API Key
</label>
<div className="flex gap-2">
<code className="flex-1 rounded-md bg-gray-100 px-4 py-2 font-mono text-sm break-all">
{generatedApiKey}
</code>
<Button
variant="outline"
size="sm"
onClick={() => handleCopyApiKey(generatedApiKey)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button variant="outline" onClick={handleCloseNewApiKeyModal}>
{generatedApiKey ? 'Cerrar' : 'Cancelar'}
</Button>
{!generatedApiKey && (
<Button onClick={handleCreateApiKey}>
<Plus className="mr-2 h-4 w-4" />
Generar API Key
</Button>
)}
</CardFooter>
</Card>
</div>
)}
{/* Delete API Key Modal */}
<ConfirmModal
isOpen={!!apiKeyToDelete}
onClose={() => 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"
/>
</div>
);
}
export default SystemSettingsPage;

View File

@ -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<UserRole, string> = {
};
export function UsersSettingsPage() {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [selectedRole, setSelectedRole] = useState<UserRole | ''>('');
const [showActiveOnly, setShowActiveOnly] = useState<boolean | undefined>(undefined);
@ -157,13 +159,14 @@ export function UsersSettingsPage() {
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
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: <Edit2 className="h-4 w-4" />,
onClick: () => console.log('Edit', user.id),
onClick: () => navigate(`/settings/users/${user.id}/edit`),
},
{
key: 'toggle',

View File

@ -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<HTMLDivElement>, 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<HTMLDivElement>) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/json', JSON.stringify(opportunity));
onDragStart(e, opportunity);
};
return (
<div
draggable
onDragStart={handleDragStart}
onClick={() => 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 */}
<div className="mb-2">
<h4 className="font-medium text-gray-900 line-clamp-2">{opportunity.name}</h4>
{opportunity.partnerName && (
<p className="text-sm text-gray-500 truncate">{opportunity.partnerName}</p>
)}
</div>
{/* Expected Revenue */}
<div className="mb-2 flex items-center gap-1.5 text-sm">
<DollarSign className="h-4 w-4 text-green-600" />
<span className="font-semibold text-green-700">
${formatCurrency(opportunity.expectedRevenue)}
</span>
</div>
{/* Footer: Date and Avatar */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
{/* Expected Close Date */}
{opportunity.expectedCloseDate ? (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Calendar className="h-3.5 w-3.5" />
<span>{formatDate(opportunity.expectedCloseDate)}</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<Calendar className="h-3.5 w-3.5" />
<span>Sin fecha</span>
</div>
)}
{/* Assigned User Avatar */}
{opportunity.assignedTo ? (
<Avatar
src={opportunity.assignedTo.avatarUrl}
fallback={opportunity.assignedTo.name}
size="xs"
/>
) : (
<Avatar fallback="?" size="xs" />
)}
</div>
{/* Probability indicator */}
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Probabilidad</span>
<span>{opportunity.probability}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-gray-200">
<div
className={cn(
'h-1.5 rounded-full transition-all',
opportunity.probability >= 70 ? 'bg-green-500' :
opportunity.probability >= 40 ? 'bg-yellow-500' : 'bg-red-500'
)}
style={{ width: `${opportunity.probability}%` }}
/>
</div>
</div>
</div>
);
}
export default KanbanCard;

View File

@ -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<StageKey, { bg: string; border: string; header: string; text: string }> = {
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<HTMLDivElement>, _opportunity: KanbanOpportunity) => {
// Data transfer is handled by KanbanCard
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
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 (
<div
className={cn(
'flex flex-col rounded-lg border-2 transition-all min-w-[280px] max-w-[320px] w-full',
colors.bg,
colors.border,
isDragOver && 'border-primary-400 ring-2 ring-primary-200'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Column Header */}
<div className={cn('rounded-t-md p-3', colors.header)}>
<div className="flex items-center justify-between">
<h3 className={cn('font-semibold', colors.text)}>{title}</h3>
<span className={cn(
'rounded-full px-2 py-0.5 text-xs font-medium',
colors.bg,
colors.text
)}>
{totalCount}
</span>
</div>
<div className="mt-1 flex items-center gap-1 text-sm">
<DollarSign className={cn('h-3.5 w-3.5', colors.text)} />
<span className={cn('font-medium', colors.text)}>
${formatCurrency(totalValue)}
</span>
</div>
</div>
{/* Cards Container */}
<div
className={cn(
'flex-1 space-y-2 overflow-y-auto p-2',
'min-h-[200px] max-h-[calc(100vh-320px)]'
)}
>
{items.length === 0 ? (
<div className={cn(
'flex h-20 items-center justify-center rounded-lg border-2 border-dashed',
colors.border,
'text-sm text-gray-400'
)}>
{isDragOver ? 'Soltar aqui' : 'Sin oportunidades'}
</div>
) : (
items.map((opportunity) => (
<KanbanCard
key={opportunity.id}
opportunity={opportunity}
onDragStart={handleDragStart}
onClick={onCardClick}
/>
))
)}
</div>
</div>
);
}
export default KanbanColumn;

View File

@ -0,0 +1,3 @@
// CRM Components
export { KanbanCard, type KanbanOpportunity, type KanbanCardProps } from './KanbanCard';
export { KanbanColumn, type StageKey, type KanbanColumnProps } from './KanbanColumn';