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:
parent
765a639004
commit
d5a703b926
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user