refactor(frontend): Refactor LotesPage and DashboardPage using common components

LotesPage (548 -> 307 lines, -44%):
- Use SearchInput, SelectField, Modal, ConfirmDialog from common
- Use DataTable instead of inline table
- Use LOTE_STATUS_OPTIONS from utils/constants
- Keep StatCard specific to lotes (with color indicator)

DashboardPage (710 -> 382 lines, -46%):
- Use formatCurrency, formatPercent, formatNumber from utils
- Use SearchInput, SelectField from common
- Remove inline utility functions (duplicated)
- Keep specialized components (StatCard, KPIGauge, EVMValueCard)

Total reduction: 569 lines removed across both files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 09:30:06 -06:00
parent 765a639004
commit d5a703b926
2 changed files with 214 additions and 781 deletions

View File

@ -1,3 +1,8 @@
/**
* DashboardPage - Portfolio Overview Dashboard
* Refactored to use common utilities and components
*/
import { useState, useMemo } from 'react';
import {
Building2,
@ -5,7 +10,6 @@ import {
DollarSign,
TrendingUp,
AlertTriangle,
Search,
CheckCircle,
Clock,
XCircle,
@ -21,37 +25,12 @@ import {
useAlerts,
useAcknowledgeAlert,
} from '../../../hooks/useReports';
import {
EarnedValueStatus,
AlertSeverity,
} from '../../../services/reports';
import { EarnedValueStatus, AlertSeverity } from '../../../services/reports';
import { SearchInput, SelectField } from '../../../components/common';
import { formatCurrency, formatPercent, formatNumber } from '../../../utils';
// =============================================================================
// Utility Functions
// =============================================================================
function formatCurrency(value: number): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatNumber(value: number, decimals: number = 2): string {
return new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
// =============================================================================
// Types
// Constants
// =============================================================================
const statusColors: Record<EarnedValueStatus, { bg: string; text: string; dot: string }> = {
@ -66,30 +45,14 @@ const statusLabels: Record<EarnedValueStatus, string> = {
red: 'Atrasado',
};
const severityConfig: Record<
AlertSeverity,
{ bg: string; text: string; border: string; icon: typeof AlertTriangle }
> = {
critical: {
bg: 'bg-red-50',
text: 'text-red-700',
border: 'border-red-200',
icon: XCircle,
},
warning: {
bg: 'bg-yellow-50',
text: 'text-yellow-700',
border: 'border-yellow-200',
icon: AlertCircle,
},
info: {
bg: 'bg-blue-50',
text: 'text-blue-700',
border: 'border-blue-200',
icon: Info,
},
const severityConfig: Record<AlertSeverity, { bg: string; text: string; border: string; icon: typeof AlertTriangle }> = {
critical: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', icon: XCircle },
warning: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200', icon: AlertCircle },
info: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', icon: Info },
};
const STATUS_OPTIONS = Object.entries(statusLabels).map(([value, label]) => ({ value, label }));
// =============================================================================
// Sub-Components
// =============================================================================
@ -104,32 +67,17 @@ interface StatCardProps {
}
function StatCard({ title, value, icon: Icon, color, subtitle, isWarning }: StatCardProps) {
const colorClasses = {
blue: 'bg-blue-500',
green: 'bg-green-500',
purple: 'bg-purple-500',
orange: 'bg-orange-500',
red: 'bg-red-500',
};
const colorClasses = { blue: 'bg-blue-500', green: 'bg-green-500', purple: 'bg-purple-500', orange: 'bg-orange-500', red: 'bg-red-500' };
return (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">{title}</p>
<p
className={clsx(
'text-2xl font-bold mt-1',
isWarning ? 'text-red-600' : 'text-gray-900'
)}
>
{value}
</p>
<p className={clsx('text-2xl font-bold mt-1', isWarning ? 'text-red-600' : 'text-gray-900')}>{value}</p>
{subtitle && <p className="text-xs text-gray-400 mt-1">{subtitle}</p>}
</div>
<div className={clsx('p-3 rounded-lg', colorClasses[color])}>
<Icon className="w-6 h-6 text-white" />
</div>
<div className={clsx('p-3 rounded-lg', colorClasses[color])}><Icon className="w-6 h-6 text-white" /></div>
</div>
</div>
);
@ -142,26 +90,10 @@ interface KPIGaugeProps {
}
function KPIGauge({ label, value, threshold = { warning: 0.95, danger: 0.85 } }: KPIGaugeProps) {
const getColor = (val: number) => {
if (val >= threshold.warning) return 'text-green-600';
if (val >= threshold.danger) return 'text-yellow-600';
return 'text-red-600';
};
const getBgColor = (val: number) => {
if (val >= threshold.warning) return 'bg-green-100';
if (val >= threshold.danger) return 'bg-yellow-100';
return 'bg-red-100';
};
const getProgressColor = (val: number) => {
if (val >= threshold.warning) return 'bg-green-500';
if (val >= threshold.danger) return 'bg-yellow-500';
return 'bg-red-500';
};
const percentage = Math.min(Math.max(value * 100, 0), 150);
const displayPercentage = Math.min(percentage, 100);
const getColor = (val: number) => val >= threshold.warning ? 'text-green-600' : val >= threshold.danger ? 'text-yellow-600' : 'text-red-600';
const getBgColor = (val: number) => val >= threshold.warning ? 'bg-green-100' : val >= threshold.danger ? 'bg-yellow-100' : 'bg-red-100';
const getProgressColor = (val: number) => val >= threshold.warning ? 'bg-green-500' : val >= threshold.danger ? 'bg-yellow-500' : 'bg-red-500';
const displayPercentage = Math.min(Math.max(value * 100, 0), 100);
return (
<div className={clsx('rounded-lg p-4', getBgColor(value))}>
@ -169,30 +101,16 @@ function KPIGauge({ label, value, threshold = { warning: 0.95, danger: 0.85 } }:
<span className="text-sm font-medium text-gray-700">{label}</span>
<Gauge className={clsx('w-5 h-5', getColor(value))} />
</div>
<div className={clsx('text-3xl font-bold', getColor(value))}>
{formatNumber(value, 2)}
</div>
<div className={clsx('text-3xl font-bold', getColor(value))}>{formatNumber(value)}</div>
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-all', getProgressColor(value))}
style={{ width: `${displayPercentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0</span>
<span>1.0</span>
<div className={clsx('h-full rounded-full transition-all', getProgressColor(value))} style={{ width: `${displayPercentage}%` }} />
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1"><span>0</span><span>1.0</span></div>
</div>
);
}
interface EVMValueCardProps {
label: string;
value: number;
description?: string;
}
function EVMValueCard({ label, value, description }: EVMValueCardProps) {
function EVMValueCard({ label, value, description }: { label: string; value: number; description?: string }) {
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500">{label}</p>
@ -211,35 +129,25 @@ export function DashboardPage() {
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<EarnedValueStatus | ''>('');
// Data hooks
const { data: stats, isLoading: statsLoading } = useDashboardStats();
const { data: projectsData, isLoading: projectsLoading } = useProjectsSummary({
status: statusFilter || undefined,
});
const { data: projectsData, isLoading: projectsLoading } = useProjectsSummary({ status: statusFilter || undefined });
const { data: kpis, isLoading: kpisLoading } = useProjectKPIs(selectedProjectId || '');
const { data: alertsData, isLoading: alertsLoading } = useAlerts({ acknowledged: false });
const acknowledgeMutation = useAcknowledgeAlert();
// Filter projects by search
const filteredProjects = useMemo(() => {
if (!projectsData?.items) return [];
if (!search) return projectsData.items;
const searchLower = search.toLowerCase();
return projectsData.items.filter((p) =>
p.nombre.toLowerCase().includes(searchLower)
);
return projectsData.items.filter((p) => p.nombre.toLowerCase().includes(searchLower));
}, [projectsData?.items, search]);
// Get selected project name
const selectedProject = useMemo(() => {
if (!selectedProjectId || !projectsData?.items) return null;
return projectsData.items.find((p) => p.id === selectedProjectId);
}, [selectedProjectId, projectsData?.items]);
const handleAcknowledge = async (alertId: string) => {
await acknowledgeMutation.mutateAsync(alertId);
};
const handleAcknowledge = async (alertId: string) => { await acknowledgeMutation.mutateAsync(alertId); };
return (
<div className="space-y-6">
@ -253,47 +161,18 @@ export function DashboardPage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{statsLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="bg-white rounded-lg shadow-sm p-6 animate-pulse"
>
<div key={i} className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-24 mb-2" />
<div className="h-8 bg-gray-200 rounded w-16" />
</div>
))
) : stats ? (
<>
<StatCard
title="Total Proyectos"
value={stats.totalProyectos}
icon={Building2}
color="blue"
/>
<StatCard
title="Proyectos Activos"
value={stats.proyectosActivos}
icon={Activity}
color="green"
/>
<StatCard
title="Presupuesto Total"
value={formatCurrency(stats.presupuestoTotal)}
icon={DollarSign}
color="purple"
/>
<StatCard
title="Avance Promedio"
value={formatPercent(stats.avancePromedio / 100)}
icon={TrendingUp}
color="orange"
/>
<StatCard
title="Alertas Activas"
value={stats.alertasActivas}
icon={AlertTriangle}
color={stats.alertasActivas > 0 ? 'red' : 'blue'}
isWarning={stats.alertasActivas > 0}
/>
<StatCard title="Total Proyectos" value={stats.totalProyectos} icon={Building2} color="blue" />
<StatCard title="Proyectos Activos" value={stats.proyectosActivos} icon={Activity} color="green" />
<StatCard title="Presupuesto Total" value={formatCurrency(stats.presupuestoTotal)} icon={DollarSign} color="purple" />
<StatCard title="Avance Promedio" value={formatPercent(stats.avancePromedio / 100)} icon={TrendingUp} color="orange" />
<StatCard title="Alertas Activas" value={stats.alertasActivas} icon={AlertTriangle} color={stats.alertasActivas > 0 ? 'red' : 'blue'} isWarning={stats.alertasActivas > 0} />
</>
) : null}
</div>
@ -305,28 +184,13 @@ export function DashboardPage() {
<div className="p-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">Resumen de Proyectos</h2>
<div className="mt-3 flex flex-col sm:flex-row gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Buscar proyecto..."
className="w-full pl-9 pr-4 py-2 text-sm border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-3 py-2 text-sm border rounded-lg focus:ring-2 focus:ring-blue-500"
<SearchInput value={search} onChange={setSearch} placeholder="Buscar proyecto..." className="flex-1" />
<SelectField
options={[{ value: '', label: 'Todos los estados' }, ...STATUS_OPTIONS]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as EarnedValueStatus | '')}
>
<option value="">Todos los estados</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
className="sm:w-48"
/>
</div>
</div>
@ -334,102 +198,36 @@ export function DashboardPage() {
{projectsLoading ? (
<div className="p-8 text-center text-gray-500">Cargando proyectos...</div>
) : filteredProjects.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No hay proyectos que coincidan con la busqueda
</div>
<div className="p-8 text-center text-gray-500">No hay proyectos que coincidan con la busqueda</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Nombre
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Presupuesto
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Real
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Programado
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
SPI
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
CPI
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nombre</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Presupuesto</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Real</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Programado</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">SPI</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">CPI</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredProjects.map((project) => (
<tr
key={project.id}
className={clsx(
'cursor-pointer transition-colors',
selectedProjectId === project.id
? 'bg-blue-50'
: 'hover:bg-gray-50'
)}
onClick={() => setSelectedProjectId(project.id)}
>
<td className="px-4 py-3 text-sm font-medium text-gray-900">
{project.nombre}
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">
{formatCurrency(project.presupuesto)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">
{formatPercent(project.avanceReal / 100)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">
{formatPercent(project.avanceProgramado / 100)}
<tr key={project.id} className={clsx('cursor-pointer transition-colors', selectedProjectId === project.id ? 'bg-blue-50' : 'hover:bg-gray-50')} onClick={() => setSelectedProjectId(project.id)}>
<td className="px-4 py-3 text-sm font-medium text-gray-900">{project.nombre}</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">{formatCurrency(project.presupuesto)}</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">{formatPercent(project.avanceReal / 100)}</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right">{formatPercent(project.avanceProgramado / 100)}</td>
<td className="px-4 py-3 text-center">
<span className={clsx('text-sm font-medium', project.spi >= 0.95 ? 'text-green-600' : project.spi >= 0.85 ? 'text-yellow-600' : 'text-red-600')}>{formatNumber(project.spi)}</span>
</td>
<td className="px-4 py-3 text-center">
<span
className={clsx(
'text-sm font-medium',
project.spi >= 0.95
? 'text-green-600'
: project.spi >= 0.85
? 'text-yellow-600'
: 'text-red-600'
)}
>
{formatNumber(project.spi)}
</span>
<span className={clsx('text-sm font-medium', project.cpi >= 0.95 ? 'text-green-600' : project.cpi >= 0.85 ? 'text-yellow-600' : 'text-red-600')}>{formatNumber(project.cpi)}</span>
</td>
<td className="px-4 py-3 text-center">
<span
className={clsx(
'text-sm font-medium',
project.cpi >= 0.95
? 'text-green-600'
: project.cpi >= 0.85
? 'text-yellow-600'
: 'text-red-600'
)}
>
{formatNumber(project.cpi)}
</span>
</td>
<td className="px-4 py-3 text-center">
<span
className={clsx(
'inline-flex items-center px-2 py-1 text-xs font-medium rounded-full',
statusColors[project.status].bg,
statusColors[project.status].text
)}
>
<span
className={clsx(
'w-1.5 h-1.5 rounded-full mr-1.5',
statusColors[project.status].dot
)}
/>
<span className={clsx('inline-flex items-center px-2 py-1 text-xs font-medium rounded-full', statusColors[project.status].bg, statusColors[project.status].text)}>
<span className={clsx('w-1.5 h-1.5 rounded-full mr-1.5', statusColors[project.status].dot)} />
{statusLabels[project.status]}
</span>
</td>
@ -446,9 +244,7 @@ export function DashboardPage() {
<div className="p-4 border-b flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Alertas Recientes</h2>
{alertsData?.items && alertsData.items.length > 0 && (
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
{alertsData.items.length}
</span>
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">{alertsData.items.length}</span>
)}
</div>
@ -464,39 +260,17 @@ export function DashboardPage() {
alertsData.items.slice(0, 10).map((alert) => {
const config = severityConfig[alert.severity];
const IconComponent = config.icon;
return (
<div
key={alert.id}
className={clsx(
'p-3 rounded-lg border',
config.bg,
config.border
)}
>
<div key={alert.id} className={clsx('p-3 rounded-lg border', config.bg, config.border)}>
<div className="flex items-start gap-3">
<IconComponent className={clsx('w-5 h-5 mt-0.5', config.text)} />
<div className="flex-1 min-w-0">
<p className={clsx('text-sm font-medium', config.text)}>
{alert.title}
</p>
<p className={clsx('text-sm font-medium', config.text)}>{alert.title}</p>
<p className="text-xs text-gray-600 mt-1">{alert.projectName}</p>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{alert.message}
</p>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{alert.message}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-400 flex items-center">
<Clock className="w-3 h-3 mr-1" />
{new Date(alert.createdAt).toLocaleDateString()}
</span>
<button
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
onClick={(e) => {
e.stopPropagation();
handleAcknowledge(alert.id);
}}
disabled={acknowledgeMutation.isPending}
>
<span className="text-xs text-gray-400 flex items-center"><Clock className="w-3 h-3 mr-1" />{new Date(alert.createdAt).toLocaleDateString()}</span>
<button className="text-xs text-blue-600 hover:text-blue-800 font-medium" onClick={(e) => { e.stopPropagation(); handleAcknowledge(alert.id); }} disabled={acknowledgeMutation.isPending}>
{acknowledgeMutation.isPending ? 'Procesando...' : 'Reconocer'}
</button>
</div>
@ -510,60 +284,38 @@ export function DashboardPage() {
</div>
</div>
{/* KPI Indicators Section - Shows when a project is selected */}
{/* KPI Indicators Section */}
{selectedProjectId && (
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-gray-900">
Indicadores KPI - {selectedProject?.nombre || 'Proyecto'}
</h2>
<p className="text-sm text-gray-500">
Earned Value Management (EVM)
</p>
<h2 className="text-lg font-semibold text-gray-900">Indicadores KPI - {selectedProject?.nombre || 'Proyecto'}</h2>
<p className="text-sm text-gray-500">Earned Value Management (EVM)</p>
</div>
<button
className="text-sm text-gray-500 hover:text-gray-700"
onClick={() => setSelectedProjectId(null)}
>
Cerrar
</button>
<button className="text-sm text-gray-500 hover:text-gray-700" onClick={() => setSelectedProjectId(null)}>Cerrar</button>
</div>
{kpisLoading ? (
<div className="text-center text-gray-500 py-8">Cargando KPIs...</div>
) : !kpis ? (
<div className="text-center text-gray-500 py-8">
No hay datos de KPIs disponibles
</div>
<div className="text-center text-gray-500 py-8">No hay datos de KPIs disponibles</div>
) : (
<div className="space-y-6">
{/* Performance Indicators */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">
Indicadores de Desempeno
</h3>
<h3 className="text-sm font-medium text-gray-700 mb-3">Indicadores de Desempeno</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<KPIGauge label="SPI (Schedule)" value={kpis.spi} />
<KPIGauge label="CPI (Cost)" value={kpis.cpi} />
<KPIGauge
label="TCPI (To Complete)"
value={kpis.tcpi}
threshold={{ warning: 1.0, danger: 1.1 }}
/>
<KPIGauge label="TCPI (To Complete)" value={kpis.tcpi} threshold={{ warning: 1.0, danger: 1.1 }} />
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">% Completado</span>
<TrendingUp className="w-5 h-5 text-blue-600" />
</div>
<div className="text-3xl font-bold text-blue-600">
{formatPercent(kpis.percentComplete / 100)}
</div>
<div className="text-3xl font-bold text-blue-600">{formatPercent(kpis.percentComplete / 100)}</div>
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(kpis.percentComplete, 100)}%` }}
/>
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${Math.min(kpis.percentComplete, 100)}%` }} />
</div>
</div>
</div>
@ -571,25 +323,11 @@ export function DashboardPage() {
{/* EV Values */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">
Valores de Earned Value
</h3>
<h3 className="text-sm font-medium text-gray-700 mb-3">Valores de Earned Value</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<EVMValueCard
label="PV (Planned Value)"
value={kpis.pv}
description="Valor planeado a la fecha"
/>
<EVMValueCard
label="EV (Earned Value)"
value={kpis.ev}
description="Valor ganado (trabajo realizado)"
/>
<EVMValueCard
label="AC (Actual Cost)"
value={kpis.ac}
description="Costo real incurrido"
/>
<EVMValueCard label="PV (Planned Value)" value={kpis.pv} description="Valor planeado a la fecha" />
<EVMValueCard label="EV (Earned Value)" value={kpis.ev} description="Valor ganado (trabajo realizado)" />
<EVMValueCard label="AC (Actual Cost)" value={kpis.ac} description="Costo real incurrido" />
</div>
</div>
@ -597,43 +335,15 @@ export function DashboardPage() {
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Varianzas</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
className={clsx(
'rounded-lg p-4',
kpis.sv >= 0 ? 'bg-green-50' : 'bg-red-50'
)}
>
<div className={clsx('rounded-lg p-4', kpis.sv >= 0 ? 'bg-green-50' : 'bg-red-50')}>
<p className="text-sm text-gray-600">SV (Schedule Variance)</p>
<p
className={clsx(
'text-xl font-semibold mt-1',
kpis.sv >= 0 ? 'text-green-700' : 'text-red-700'
)}
>
{formatCurrency(kpis.sv)}
</p>
<p className="text-xs text-gray-500 mt-1">
{kpis.sv >= 0 ? 'Adelantado' : 'Atrasado'}
</p>
<p className={clsx('text-xl font-semibold mt-1', kpis.sv >= 0 ? 'text-green-700' : 'text-red-700')}>{formatCurrency(kpis.sv)}</p>
<p className="text-xs text-gray-500 mt-1">{kpis.sv >= 0 ? 'Adelantado' : 'Atrasado'}</p>
</div>
<div
className={clsx(
'rounded-lg p-4',
kpis.cv >= 0 ? 'bg-green-50' : 'bg-red-50'
)}
>
<div className={clsx('rounded-lg p-4', kpis.cv >= 0 ? 'bg-green-50' : 'bg-red-50')}>
<p className="text-sm text-gray-600">CV (Cost Variance)</p>
<p
className={clsx(
'text-xl font-semibold mt-1',
kpis.cv >= 0 ? 'text-green-700' : 'text-red-700'
)}
>
{formatCurrency(kpis.cv)}
</p>
<p className="text-xs text-gray-500 mt-1">
{kpis.cv >= 0 ? 'Bajo presupuesto' : 'Sobre presupuesto'}
</p>
<p className={clsx('text-xl font-semibold mt-1', kpis.cv >= 0 ? 'text-green-700' : 'text-red-700')}>{formatCurrency(kpis.cv)}</p>
<p className="text-xs text-gray-500 mt-1">{kpis.cv >= 0 ? 'Bajo presupuesto' : 'Sobre presupuesto'}</p>
</div>
</div>
</div>
@ -642,63 +352,26 @@ export function DashboardPage() {
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Proyecciones</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<EVMValueCard
label="BAC (Budget at Completion)"
value={kpis.bac}
description="Presupuesto total del proyecto"
/>
<EVMValueCard
label="EAC (Estimate at Completion)"
value={kpis.eac}
description="Costo estimado final"
/>
<EVMValueCard
label="ETC (Estimate to Complete)"
value={kpis.etc}
description="Costo estimado para terminar"
/>
<EVMValueCard label="BAC (Budget at Completion)" value={kpis.bac} description="Presupuesto total del proyecto" />
<EVMValueCard label="EAC (Estimate at Completion)" value={kpis.eac} description="Costo estimado final" />
<EVMValueCard label="ETC (Estimate to Complete)" value={kpis.etc} description="Costo estimado para terminar" />
</div>
</div>
{/* VAC */}
<div>
<div
className={clsx(
'rounded-lg p-4',
kpis.vac >= 0 ? 'bg-green-50' : 'bg-red-50'
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">VAC (Variance at Completion)</p>
<p
className={clsx(
'text-2xl font-bold mt-1',
kpis.vac >= 0 ? 'text-green-700' : 'text-red-700'
)}
>
{formatCurrency(kpis.vac)}
</p>
</div>
<div
className={clsx(
'p-3 rounded-full',
kpis.vac >= 0 ? 'bg-green-100' : 'bg-red-100'
)}
>
{kpis.vac >= 0 ? (
<TrendingUp className="w-8 h-8 text-green-600" />
) : (
<AlertTriangle className="w-8 h-8 text-red-600" />
)}
</div>
<div className={clsx('rounded-lg p-4', kpis.vac >= 0 ? 'bg-green-50' : 'bg-red-50')}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">VAC (Variance at Completion)</p>
<p className={clsx('text-2xl font-bold mt-1', kpis.vac >= 0 ? 'text-green-700' : 'text-red-700')}>{formatCurrency(kpis.vac)}</p>
</div>
<div className={clsx('p-3 rounded-full', kpis.vac >= 0 ? 'bg-green-100' : 'bg-red-100')}>
{kpis.vac >= 0 ? <TrendingUp className="w-8 h-8 text-green-600" /> : <AlertTriangle className="w-8 h-8 text-red-600" />}
</div>
<p className="text-sm text-gray-500 mt-2">
{kpis.vac >= 0
? `Se proyecta un ahorro de ${formatCurrency(kpis.vac)} al finalizar el proyecto`
: `Se proyecta un sobrecosto de ${formatCurrency(Math.abs(kpis.vac))} al finalizar el proyecto`}
</p>
</div>
<p className="text-sm text-gray-500 mt-2">
{kpis.vac >= 0 ? `Se proyecta un ahorro de ${formatCurrency(kpis.vac)} al finalizar el proyecto` : `Se proyecta un sobrecosto de ${formatCurrency(Math.abs(kpis.vac))} al finalizar el proyecto`}
</p>
</div>
</div>
)}

View File

@ -1,6 +1,11 @@
/**
* LotesPage - Lotes Management
* Refactored to use common components
*/
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Plus, Trash2, Search, RefreshCw } from 'lucide-react';
import { Plus, RefreshCw, Trash2 } from 'lucide-react';
import {
useLotes,
useLoteStats,
@ -11,18 +16,24 @@ import {
useUpdateLoteStatus,
useAssignPrototipo,
} from '../../../hooks/useConstruccion';
import { LoteStatus, CreateLoteDto } from '../../../services/construccion/lotes.api';
import type { LoteStatus, CreateLoteDto, Lote } from '../../../types';
import { HierarchyBreadcrumb, LoteStatusBadge, getStatusColor } from '../../../components/proyectos';
import {
PageHeader,
PageHeaderAction,
DataTable,
SearchInput,
SelectField,
Modal,
ModalFooter,
TextInput,
FormGroup,
ConfirmDialog,
} from '../../../components/common';
import type { DataTableColumn } from '../../../components/common';
import { LOTE_STATUS_OPTIONS } from '../../../utils';
import clsx from 'clsx';
const statusLabels: Record<LoteStatus, string> = {
available: 'Disponible',
reserved: 'Reservado',
sold: 'Vendido',
blocked: 'Bloqueado',
in_construction: 'En Construccion',
};
export function LotesPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState('');
@ -30,7 +41,7 @@ export function LotesPage() {
const [showModal, setShowModal] = useState(false);
const [showStatusModal, setShowStatusModal] = useState<string | null>(null);
const [showPrototipoModal, setShowPrototipoModal] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const manzanaId = searchParams.get('manzanaId') || '';
@ -52,9 +63,11 @@ export function LotesPage() {
const manzanas = manzanasData?.items || [];
const prototipos = prototiposData?.items || [];
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
const handleDelete = async () => {
if (deleteId) {
await deleteMutation.mutateAsync(deleteId);
setDeleteId(null);
}
};
const handleCreate = async (formData: CreateLoteDto) => {
@ -72,23 +85,38 @@ export function LotesPage() {
setShowPrototipoModal(null);
};
const statusOptions = LOTE_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label }));
const manzanaOptions = manzanas.map(m => ({ value: m.id, label: `${m.code} - ${m.name}` }));
const columns: DataTableColumn<Lote>[] = [
{ key: 'code', header: 'Codigo', render: (item) => <span className="font-medium text-gray-900">{item.code}</span> },
{ key: 'officialNumber', header: 'No. Oficial', render: (item) => <span className="text-gray-500">{item.officialNumber || '-'}</span> },
{ key: 'areaM2', header: 'Area (m2)', render: (item) => <span className="text-gray-500">{item.areaM2.toFixed(2)}</span> },
{ key: 'prototipo', header: 'Prototipo', render: (item) => (
item.prototipo ? (
<span className="text-blue-600">{item.prototipo.name}</span>
) : (
<button className="text-gray-400 hover:text-blue-600 underline" onClick={() => setShowPrototipoModal(item.id)}>Asignar</button>
)
)},
{ key: 'status', header: 'Estado', render: (item) => <LoteStatusBadge status={item.status} /> },
{ key: 'actions', header: 'Acciones', align: 'right', render: (item) => (
<div className="flex items-center justify-end gap-1">
<button className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg" title="Cambiar estado" onClick={(e) => { e.stopPropagation(); setShowStatusModal(item.id); }}><RefreshCw className="w-4 h-4" /></button>
<button className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg" title="Eliminar" onClick={(e) => { e.stopPropagation(); setDeleteId(item.id); }}><Trash2 className="w-4 h-4" /></button>
</div>
)},
];
return (
<div>
<HierarchyBreadcrumb items={[{ label: 'Lotes' }]} />
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Lotes</h1>
<p className="text-gray-600">Gestion de lotes y terrenos</p>
</div>
<button
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
onClick={() => setShowModal(true)}
>
<Plus className="w-5 h-5 mr-2" />
Nuevo Lote
</button>
</div>
<PageHeader
title="Lotes"
description="Gestion de lotes y terrenos"
actions={<PageHeaderAction onClick={() => setShowModal(true)}><Plus className="w-5 h-5 mr-2" />Nuevo Lote</PageHeaderAction>}
/>
{/* Stats Cards */}
{stats && (
@ -98,29 +126,16 @@ export function LotesPage() {
<StatCard label="Reservados" value={stats.reserved} color={getStatusColor('reserved')} />
<StatCard label="Vendidos" value={stats.sold} color={getStatusColor('sold')} />
<StatCard label="Bloqueados" value={stats.blocked} color={getStatusColor('blocked')} />
<StatCard
label="En Construccion"
value={stats.inConstruction}
color={getStatusColor('in_construction')}
/>
<StatCard label="En Construccion" value={stats.inConstruction} color={getStatusColor('in_construction')} />
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar por codigo..."
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
<SearchInput value={search} onChange={setSearch} placeholder="Buscar por codigo..." className="flex-1" />
<SelectField
options={[{ value: '', label: 'Todas las manzanas' }, ...manzanaOptions]}
value={manzanaId}
onChange={(e) => {
if (e.target.value) {
@ -130,112 +145,18 @@ export function LotesPage() {
}
setSearchParams(searchParams);
}}
>
<option value="">Todas las manzanas</option>
{manzanas.map((m) => (
<option key={m.id} value={m.id}>
{m.code} - {m.name}
</option>
))}
</select>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
className="sm:w-56"
/>
<SelectField
options={[{ value: '', label: 'Todos los estados' }, ...statusOptions]}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as LoteStatus | '')}
>
<option value="">Todos los estados</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
className="sm:w-48"
/>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-gray-500">Cargando...</div>
) : error ? (
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
) : lotes.length === 0 ? (
<div className="p-8 text-center text-gray-500">No hay lotes</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Codigo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
No. Oficial
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Area (m2)
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Prototipo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{lotes.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.code}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.officialNumber || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.areaM2.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.prototipo ? (
<span className="text-blue-600">{item.prototipo.name}</span>
) : (
<button
className="text-gray-400 hover:text-blue-600 underline"
onClick={() => setShowPrototipoModal(item.id)}
>
Asignar
</button>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<LoteStatusBadge status={item.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Cambiar estado"
onClick={() => setShowStatusModal(item.id)}
>
<RefreshCw className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
onClick={() => setDeleteConfirm(item.id)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DataTable data={lotes} columns={columns} isLoading={isLoading} error={error ? 'Error al cargar los datos' : null} emptyState={{ title: 'No hay lotes', description: 'No se han registrado lotes.' }} />
{/* Create Modal */}
{showModal && (
@ -268,34 +189,21 @@ export function LotesPage() {
/>
)}
{/* Delete Confirmation */}
{deleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
<p className="text-gray-600 mb-6">¿Esta seguro de eliminar este lote?</p>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={() => setDeleteConfirm(null)}
>
Cancelar
</button>
<button
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
onClick={() => handleDelete(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Confirmar eliminacion"
message="¿Esta seguro de eliminar este lote? Esta accion no se puede deshacer."
confirmLabel="Eliminar"
variant="danger"
isLoading={deleteMutation.isPending}
/>
</div>
);
}
// Stats Card Component (specific to lotes with color indicator)
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="bg-white rounded-lg shadow-sm p-4">
@ -308,6 +216,7 @@ function StatCard({ label, value, color }: { label: string; value: number; color
);
}
// Create Lote Modal
interface CreateLoteModalProps {
manzanas: { id: string; code: string; name: string }[];
defaultManzanaId: string;
@ -316,13 +225,7 @@ interface CreateLoteModalProps {
isLoading: boolean;
}
function CreateLoteModal({
manzanas,
defaultManzanaId,
onClose,
onSubmit,
isLoading,
}: CreateLoteModalProps) {
function CreateLoteModal({ manzanas, defaultManzanaId, onClose, onSubmit, isLoading }: CreateLoteModalProps) {
const [formData, setFormData] = useState<CreateLoteDto>({
code: '',
manzanaId: defaultManzanaId || '',
@ -332,113 +235,31 @@ function CreateLoteModal({
status: 'available',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await onSubmit(formData); };
const update = (field: keyof CreateLoteDto, value: string | number) => setFormData({ ...formData, [field]: value });
const manzanaOptions = manzanas.map(m => ({ value: m.id, label: `${m.code} - ${m.name}` }));
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Nuevo Lote</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Manzana *</label>
<select
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.manzanaId}
onChange={(e) => setFormData({ ...formData, manzanaId: e.target.value })}
>
<option value="">Seleccionar manzana</option>
{manzanas.map((m) => (
<option key={m.id} value={m.id}>
{m.code} - {m.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo *</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">No. Oficial</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.officialNumber || ''}
onChange={(e) => setFormData({ ...formData, officialNumber: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Area (m2) *</label>
<input
type="number"
required
step="0.01"
min="0"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.areaM2}
onChange={(e) => setFormData({ ...formData, areaM2: parseFloat(e.target.value) })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Frente (m) *</label>
<input
type="number"
required
step="0.01"
min="0"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.frontM}
onChange={(e) => setFormData({ ...formData, frontM: parseFloat(e.target.value) })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Fondo (m) *</label>
<input
type="number"
required
step="0.01"
min="0"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.depthM}
onChange={(e) => setFormData({ ...formData, depthM: parseFloat(e.target.value) })}
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Creando...' : 'Crear Lote'}
</button>
</div>
</form>
</div>
</div>
<Modal isOpen={true} onClose={onClose} title="Nuevo Lote" size="md"
footer={<ModalFooter><button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>Cancelar</button><button type="submit" form="lote-form" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" disabled={isLoading}>{isLoading ? 'Creando...' : 'Crear Lote'}</button></ModalFooter>}>
<form id="lote-form" onSubmit={handleSubmit} className="space-y-4">
<SelectField label="Manzana" required options={[{ value: '', label: 'Seleccionar manzana' }, ...manzanaOptions]} value={formData.manzanaId} onChange={(e) => update('manzanaId', e.target.value)} />
<FormGroup cols={2}>
<TextInput label="Codigo" required value={formData.code} onChange={(e) => update('code', e.target.value)} />
<TextInput label="No. Oficial" value={formData.officialNumber || ''} onChange={(e) => update('officialNumber', e.target.value)} />
</FormGroup>
<FormGroup cols={3}>
<TextInput label="Area (m2)" type="number" required step="0.01" min="0" value={formData.areaM2} onChange={(e) => update('areaM2', parseFloat(e.target.value))} />
<TextInput label="Frente (m)" type="number" required step="0.01" min="0" value={formData.frontM} onChange={(e) => update('frontM', parseFloat(e.target.value))} />
<TextInput label="Fondo (m)" type="number" required step="0.01" min="0" value={formData.depthM} onChange={(e) => update('depthM', parseFloat(e.target.value))} />
</FormGroup>
</form>
</Modal>
);
}
// Status Change Modal
interface StatusChangeModalProps {
currentStatus: LoteStatus;
onClose: () => void;
@ -450,51 +271,22 @@ function StatusChangeModal({ currentStatus, onClose, onSubmit, isLoading }: Stat
const [status, setStatus] = useState<LoteStatus>(currentStatus);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Cambiar Estado</h3>
<div className="space-y-3 mb-6">
{(Object.entries(statusLabels) as [LoteStatus, string][]).map(([value, label]) => (
<label
key={value}
className={clsx(
'flex items-center p-3 border rounded-lg cursor-pointer transition-colors',
status === value ? 'border-blue-500 bg-blue-50' : 'hover:bg-gray-50'
)}
>
<input
type="radio"
name="status"
value={value}
checked={status === value}
onChange={(e) => setStatus(e.target.value as LoteStatus)}
className="sr-only"
/>
<LoteStatusBadge status={value} />
<span className="ml-3 text-sm text-gray-700">{label}</span>
</label>
))}
</div>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
onClick={() => onSubmit(status)}
disabled={isLoading || status === currentStatus}
>
{isLoading ? 'Actualizando...' : 'Actualizar'}
</button>
</div>
<Modal isOpen={true} onClose={onClose} title="Cambiar Estado" size="sm"
footer={<ModalFooter><button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>Cancelar</button><button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" onClick={() => onSubmit(status)} disabled={isLoading || status === currentStatus}>{isLoading ? 'Actualizando...' : 'Actualizar'}</button></ModalFooter>}>
<div className="space-y-3">
{LOTE_STATUS_OPTIONS.map((option) => (
<label key={option.value} className={clsx('flex items-center p-3 border rounded-lg cursor-pointer transition-colors', status === option.value ? 'border-blue-500 bg-blue-50' : 'hover:bg-gray-50')}>
<input type="radio" name="status" value={option.value} checked={status === option.value} onChange={(e) => setStatus(e.target.value as LoteStatus)} className="sr-only" />
<LoteStatusBadge status={option.value} />
<span className="ml-3 text-sm text-gray-700">{option.label}</span>
</label>
))}
</div>
</div>
</Modal>
);
}
// Assign Prototipo Modal
interface AssignPrototipoModalProps {
prototipos: { id: string; code: string; name: string }[];
onClose: () => void;
@ -502,46 +294,14 @@ interface AssignPrototipoModalProps {
isLoading: boolean;
}
function AssignPrototipoModal({
prototipos,
onClose,
onSubmit,
isLoading,
}: AssignPrototipoModalProps) {
function AssignPrototipoModal({ prototipos, onClose, onSubmit, isLoading }: AssignPrototipoModalProps) {
const [prototipoId, setPrototipoId] = useState('');
const prototipoOptions = prototipos.map(p => ({ value: p.id, label: `${p.code} - ${p.name}` }));
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Asignar Prototipo</h3>
<select
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 mb-6"
value={prototipoId}
onChange={(e) => setPrototipoId(e.target.value)}
>
<option value="">Seleccionar prototipo</option>
{prototipos.map((p) => (
<option key={p.id} value={p.id}>
{p.code} - {p.name}
</option>
))}
</select>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
onClick={() => onSubmit(prototipoId)}
disabled={isLoading || !prototipoId}
>
{isLoading ? 'Asignando...' : 'Asignar'}
</button>
</div>
</div>
</div>
<Modal isOpen={true} onClose={onClose} title="Asignar Prototipo" size="sm"
footer={<ModalFooter><button type="button" className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50" onClick={onClose}>Cancelar</button><button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" onClick={() => onSubmit(prototipoId)} disabled={isLoading || !prototipoId}>{isLoading ? 'Asignando...' : 'Asignar'}</button></ModalFooter>}>
<SelectField options={[{ value: '', label: 'Seleccionar prototipo' }, ...prototipoOptions]} value={prototipoId} onChange={(e) => setPrototipoId(e.target.value)} />
</Modal>
);
}