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 { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@ -5,7 +10,6 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Search,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
XCircle,
|
XCircle,
|
||||||
@ -21,37 +25,12 @@ import {
|
|||||||
useAlerts,
|
useAlerts,
|
||||||
useAcknowledgeAlert,
|
useAcknowledgeAlert,
|
||||||
} from '../../../hooks/useReports';
|
} from '../../../hooks/useReports';
|
||||||
import {
|
import { EarnedValueStatus, AlertSeverity } from '../../../services/reports';
|
||||||
EarnedValueStatus,
|
import { SearchInput, SelectField } from '../../../components/common';
|
||||||
AlertSeverity,
|
import { formatCurrency, formatPercent, formatNumber } from '../../../utils';
|
||||||
} from '../../../services/reports';
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Utility Functions
|
// Constants
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
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
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const statusColors: Record<EarnedValueStatus, { bg: string; text: string; dot: string }> = {
|
const statusColors: Record<EarnedValueStatus, { bg: string; text: string; dot: string }> = {
|
||||||
@ -66,30 +45,14 @@ const statusLabels: Record<EarnedValueStatus, string> = {
|
|||||||
red: 'Atrasado',
|
red: 'Atrasado',
|
||||||
};
|
};
|
||||||
|
|
||||||
const severityConfig: Record<
|
const severityConfig: Record<AlertSeverity, { bg: string; text: string; border: string; icon: typeof AlertTriangle }> = {
|
||||||
AlertSeverity,
|
critical: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', icon: XCircle },
|
||||||
{ bg: string; text: string; border: string; icon: typeof AlertTriangle }
|
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 },
|
||||||
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
|
// Sub-Components
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -104,32 +67,17 @@ interface StatCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ title, value, icon: Icon, color, subtitle, isWarning }: StatCardProps) {
|
function StatCard({ title, value, icon: Icon, color, subtitle, isWarning }: StatCardProps) {
|
||||||
const colorClasses = {
|
const colorClasses = { blue: 'bg-blue-500', green: 'bg-green-500', purple: 'bg-purple-500', orange: 'bg-orange-500', red: 'bg-red-500' };
|
||||||
blue: 'bg-blue-500',
|
|
||||||
green: 'bg-green-500',
|
|
||||||
purple: 'bg-purple-500',
|
|
||||||
orange: 'bg-orange-500',
|
|
||||||
red: 'bg-red-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-500">{title}</p>
|
<p className="text-sm font-medium text-gray-500">{title}</p>
|
||||||
<p
|
<p className={clsx('text-2xl font-bold mt-1', isWarning ? 'text-red-600' : 'text-gray-900')}>{value}</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>}
|
{subtitle && <p className="text-xs text-gray-400 mt-1">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx('p-3 rounded-lg', colorClasses[color])}>
|
<div className={clsx('p-3 rounded-lg', colorClasses[color])}><Icon className="w-6 h-6 text-white" /></div>
|
||||||
<Icon className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -142,26 +90,10 @@ interface KPIGaugeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function KPIGauge({ label, value, threshold = { warning: 0.95, danger: 0.85 } }: KPIGaugeProps) {
|
function KPIGauge({ label, value, threshold = { warning: 0.95, danger: 0.85 } }: KPIGaugeProps) {
|
||||||
const getColor = (val: number) => {
|
const getColor = (val: number) => val >= threshold.warning ? 'text-green-600' : val >= threshold.danger ? 'text-yellow-600' : 'text-red-600';
|
||||||
if (val >= threshold.warning) return 'text-green-600';
|
const getBgColor = (val: number) => val >= threshold.warning ? 'bg-green-100' : val >= threshold.danger ? 'bg-yellow-100' : 'bg-red-100';
|
||||||
if (val >= threshold.danger) return 'text-yellow-600';
|
const getProgressColor = (val: number) => val >= threshold.warning ? 'bg-green-500' : val >= threshold.danger ? 'bg-yellow-500' : 'bg-red-500';
|
||||||
return 'text-red-600';
|
const displayPercentage = Math.min(Math.max(value * 100, 0), 100);
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('rounded-lg p-4', getBgColor(value))}>
|
<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>
|
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||||
<Gauge className={clsx('w-5 h-5', getColor(value))} />
|
<Gauge className={clsx('w-5 h-5', getColor(value))} />
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx('text-3xl font-bold', getColor(value))}>
|
<div className={clsx('text-3xl font-bold', getColor(value))}>{formatNumber(value)}</div>
|
||||||
{formatNumber(value, 2)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
|
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
|
||||||
<div
|
<div className={clsx('h-full rounded-full transition-all', getProgressColor(value))} style={{ width: `${displayPercentage}%` }} />
|
||||||
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>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mt-1"><span>0</span><span>1.0</span></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EVMValueCardProps {
|
function EVMValueCard({ label, value, description }: { label: string; value: number; description?: string }) {
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EVMValueCard({ label, value, description }: EVMValueCardProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<p className="text-sm text-gray-500">{label}</p>
|
<p className="text-sm text-gray-500">{label}</p>
|
||||||
@ -211,35 +129,25 @@ export function DashboardPage() {
|
|||||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||||
const [statusFilter, setStatusFilter] = useState<EarnedValueStatus | ''>('');
|
const [statusFilter, setStatusFilter] = useState<EarnedValueStatus | ''>('');
|
||||||
|
|
||||||
// Data hooks
|
|
||||||
const { data: stats, isLoading: statsLoading } = useDashboardStats();
|
const { data: stats, isLoading: statsLoading } = useDashboardStats();
|
||||||
const { data: projectsData, isLoading: projectsLoading } = useProjectsSummary({
|
const { data: projectsData, isLoading: projectsLoading } = useProjectsSummary({ status: statusFilter || undefined });
|
||||||
status: statusFilter || undefined,
|
|
||||||
});
|
|
||||||
const { data: kpis, isLoading: kpisLoading } = useProjectKPIs(selectedProjectId || '');
|
const { data: kpis, isLoading: kpisLoading } = useProjectKPIs(selectedProjectId || '');
|
||||||
const { data: alertsData, isLoading: alertsLoading } = useAlerts({ acknowledged: false });
|
const { data: alertsData, isLoading: alertsLoading } = useAlerts({ acknowledged: false });
|
||||||
|
|
||||||
const acknowledgeMutation = useAcknowledgeAlert();
|
const acknowledgeMutation = useAcknowledgeAlert();
|
||||||
|
|
||||||
// Filter projects by search
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
if (!projectsData?.items) return [];
|
if (!projectsData?.items) return [];
|
||||||
if (!search) return projectsData.items;
|
if (!search) return projectsData.items;
|
||||||
const searchLower = search.toLowerCase();
|
const searchLower = search.toLowerCase();
|
||||||
return projectsData.items.filter((p) =>
|
return projectsData.items.filter((p) => p.nombre.toLowerCase().includes(searchLower));
|
||||||
p.nombre.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
}, [projectsData?.items, search]);
|
}, [projectsData?.items, search]);
|
||||||
|
|
||||||
// Get selected project name
|
|
||||||
const selectedProject = useMemo(() => {
|
const selectedProject = useMemo(() => {
|
||||||
if (!selectedProjectId || !projectsData?.items) return null;
|
if (!selectedProjectId || !projectsData?.items) return null;
|
||||||
return projectsData.items.find((p) => p.id === selectedProjectId);
|
return projectsData.items.find((p) => p.id === selectedProjectId);
|
||||||
}, [selectedProjectId, projectsData?.items]);
|
}, [selectedProjectId, projectsData?.items]);
|
||||||
|
|
||||||
const handleAcknowledge = async (alertId: string) => {
|
const handleAcknowledge = async (alertId: string) => { await acknowledgeMutation.mutateAsync(alertId); };
|
||||||
await acknowledgeMutation.mutateAsync(alertId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div
|
<div key={i} className="bg-white rounded-lg shadow-sm p-6 animate-pulse">
|
||||||
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-4 bg-gray-200 rounded w-24 mb-2" />
|
||||||
<div className="h-8 bg-gray-200 rounded w-16" />
|
<div className="h-8 bg-gray-200 rounded w-16" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : stats ? (
|
) : stats ? (
|
||||||
<>
|
<>
|
||||||
<StatCard
|
<StatCard title="Total Proyectos" value={stats.totalProyectos} icon={Building2} color="blue" />
|
||||||
title="Total Proyectos"
|
<StatCard title="Proyectos Activos" value={stats.proyectosActivos} icon={Activity} color="green" />
|
||||||
value={stats.totalProyectos}
|
<StatCard title="Presupuesto Total" value={formatCurrency(stats.presupuestoTotal)} icon={DollarSign} color="purple" />
|
||||||
icon={Building2}
|
<StatCard title="Avance Promedio" value={formatPercent(stats.avancePromedio / 100)} icon={TrendingUp} color="orange" />
|
||||||
color="blue"
|
<StatCard title="Alertas Activas" value={stats.alertasActivas} icon={AlertTriangle} color={stats.alertasActivas > 0 ? 'red' : 'blue'} isWarning={stats.alertasActivas > 0} />
|
||||||
/>
|
|
||||||
<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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -305,28 +184,13 @@ export function DashboardPage() {
|
|||||||
<div className="p-4 border-b">
|
<div className="p-4 border-b">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Resumen de Proyectos</h2>
|
<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="mt-3 flex flex-col sm:flex-row gap-3">
|
||||||
<div className="flex-1 relative">
|
<SearchInput value={search} onChange={setSearch} placeholder="Buscar proyecto..." className="flex-1" />
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<SelectField
|
||||||
<input
|
options={[{ value: '', label: 'Todos los estados' }, ...STATUS_OPTIONS]}
|
||||||
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"
|
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value as EarnedValueStatus | '')}
|
onChange={(e) => setStatusFilter(e.target.value as EarnedValueStatus | '')}
|
||||||
>
|
className="sm:w-48"
|
||||||
<option value="">Todos los estados</option>
|
/>
|
||||||
{Object.entries(statusLabels).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -334,102 +198,36 @@ export function DashboardPage() {
|
|||||||
{projectsLoading ? (
|
{projectsLoading ? (
|
||||||
<div className="p-8 text-center text-gray-500">Cargando proyectos...</div>
|
<div className="p-8 text-center text-gray-500">Cargando proyectos...</div>
|
||||||
) : filteredProjects.length === 0 ? (
|
) : filteredProjects.length === 0 ? (
|
||||||
<div className="p-8 text-center text-gray-500">
|
<div className="p-8 text-center text-gray-500">No hay proyectos que coincidan con la busqueda</div>
|
||||||
No hay proyectos que coincidan con la busqueda
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nombre</th>
|
||||||
Nombre
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Presupuesto</th>
|
||||||
</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">
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Programado</th>
|
||||||
Presupuesto
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">SPI</th>
|
||||||
</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-right text-xs font-medium text-gray-500 uppercase">
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||||
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{filteredProjects.map((project) => (
|
{filteredProjects.map((project) => (
|
||||||
<tr
|
<tr key={project.id} className={clsx('cursor-pointer transition-colors', selectedProjectId === project.id ? 'bg-blue-50' : 'hover:bg-gray-50')} onClick={() => setSelectedProjectId(project.id)}>
|
||||||
key={project.id}
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">{project.nombre}</td>
|
||||||
className={clsx(
|
<td className="px-4 py-3 text-sm text-gray-600 text-right">{formatCurrency(project.presupuesto)}</td>
|
||||||
'cursor-pointer transition-colors',
|
<td className="px-4 py-3 text-sm text-gray-600 text-right">{formatPercent(project.avanceReal / 100)}</td>
|
||||||
selectedProjectId === project.id
|
<td className="px-4 py-3 text-sm text-gray-600 text-right">{formatPercent(project.avanceProgramado / 100)}</td>
|
||||||
? 'bg-blue-50'
|
<td className="px-4 py-3 text-center">
|
||||||
: 'hover:bg-gray-50'
|
<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>
|
||||||
)}
|
|
||||||
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>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<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>
|
||||||
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>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<span
|
<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)}>
|
||||||
className={clsx(
|
<span className={clsx('w-1.5 h-1.5 rounded-full mr-1.5', statusColors[project.status].dot)} />
|
||||||
'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
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{statusLabels[project.status]}
|
{statusLabels[project.status]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@ -446,9 +244,7 @@ export function DashboardPage() {
|
|||||||
<div className="p-4 border-b flex items-center justify-between">
|
<div className="p-4 border-b flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Alertas Recientes</h2>
|
<h2 className="text-lg font-semibold text-gray-900">Alertas Recientes</h2>
|
||||||
{alertsData?.items && alertsData.items.length > 0 && (
|
{alertsData?.items && alertsData.items.length > 0 && (
|
||||||
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
|
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">{alertsData.items.length}</span>
|
||||||
{alertsData.items.length}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -464,39 +260,17 @@ export function DashboardPage() {
|
|||||||
alertsData.items.slice(0, 10).map((alert) => {
|
alertsData.items.slice(0, 10).map((alert) => {
|
||||||
const config = severityConfig[alert.severity];
|
const config = severityConfig[alert.severity];
|
||||||
const IconComponent = config.icon;
|
const IconComponent = config.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={alert.id} className={clsx('p-3 rounded-lg border', config.bg, config.border)}>
|
||||||
key={alert.id}
|
|
||||||
className={clsx(
|
|
||||||
'p-3 rounded-lg border',
|
|
||||||
config.bg,
|
|
||||||
config.border
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<IconComponent className={clsx('w-5 h-5 mt-0.5', config.text)} />
|
<IconComponent className={clsx('w-5 h-5 mt-0.5', config.text)} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={clsx('text-sm font-medium', config.text)}>
|
<p className={clsx('text-sm font-medium', config.text)}>{alert.title}</p>
|
||||||
{alert.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-600 mt-1">{alert.projectName}</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">
|
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{alert.message}</p>
|
||||||
{alert.message}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-between mt-2">
|
||||||
<span className="text-xs text-gray-400 flex items-center">
|
<span className="text-xs text-gray-400 flex items-center"><Clock className="w-3 h-3 mr-1" />{new Date(alert.createdAt).toLocaleDateString()}</span>
|
||||||
<Clock className="w-3 h-3 mr-1" />
|
<button className="text-xs text-blue-600 hover:text-blue-800 font-medium" onClick={(e) => { e.stopPropagation(); handleAcknowledge(alert.id); }} disabled={acknowledgeMutation.isPending}>
|
||||||
{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'}
|
{acknowledgeMutation.isPending ? 'Procesando...' : 'Reconocer'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -510,60 +284,38 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Indicators Section - Shows when a project is selected */}
|
{/* KPI Indicators Section */}
|
||||||
{selectedProjectId && (
|
{selectedProjectId && (
|
||||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">Indicadores KPI - {selectedProject?.nombre || 'Proyecto'}</h2>
|
||||||
Indicadores KPI - {selectedProject?.nombre || 'Proyecto'}
|
<p className="text-sm text-gray-500">Earned Value Management (EVM)</p>
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Earned Value Management (EVM)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button className="text-sm text-gray-500 hover:text-gray-700" onClick={() => setSelectedProjectId(null)}>Cerrar</button>
|
||||||
className="text-sm text-gray-500 hover:text-gray-700"
|
|
||||||
onClick={() => setSelectedProjectId(null)}
|
|
||||||
>
|
|
||||||
Cerrar
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{kpisLoading ? (
|
{kpisLoading ? (
|
||||||
<div className="text-center text-gray-500 py-8">Cargando KPIs...</div>
|
<div className="text-center text-gray-500 py-8">Cargando KPIs...</div>
|
||||||
) : !kpis ? (
|
) : !kpis ? (
|
||||||
<div className="text-center text-gray-500 py-8">
|
<div className="text-center text-gray-500 py-8">No hay datos de KPIs disponibles</div>
|
||||||
No hay datos de KPIs disponibles
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Performance Indicators */}
|
{/* Performance Indicators */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-700 mb-3">
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Indicadores de Desempeno</h3>
|
||||||
Indicadores de Desempeno
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<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="SPI (Schedule)" value={kpis.spi} />
|
||||||
<KPIGauge label="CPI (Cost)" value={kpis.cpi} />
|
<KPIGauge label="CPI (Cost)" value={kpis.cpi} />
|
||||||
<KPIGauge
|
<KPIGauge label="TCPI (To Complete)" value={kpis.tcpi} threshold={{ warning: 1.0, danger: 1.1 }} />
|
||||||
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="bg-blue-50 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm font-medium text-gray-700">% Completado</span>
|
<span className="text-sm font-medium text-gray-700">% Completado</span>
|
||||||
<TrendingUp className="w-5 h-5 text-blue-600" />
|
<TrendingUp className="w-5 h-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-bold text-blue-600">
|
<div className="text-3xl font-bold text-blue-600">{formatPercent(kpis.percentComplete / 100)}</div>
|
||||||
{formatPercent(kpis.percentComplete / 100)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
|
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
|
||||||
<div
|
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${Math.min(kpis.percentComplete, 100)}%` }} />
|
||||||
className="h-full bg-blue-500 rounded-full transition-all"
|
|
||||||
style={{ width: `${Math.min(kpis.percentComplete, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -571,25 +323,11 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
{/* EV Values */}
|
{/* EV Values */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-700 mb-3">
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Valores de Earned Value</h3>
|
||||||
Valores de Earned Value
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<EVMValueCard
|
<EVMValueCard label="PV (Planned Value)" value={kpis.pv} description="Valor planeado a la fecha" />
|
||||||
label="PV (Planned Value)"
|
<EVMValueCard label="EV (Earned Value)" value={kpis.ev} description="Valor ganado (trabajo realizado)" />
|
||||||
value={kpis.pv}
|
<EVMValueCard label="AC (Actual Cost)" value={kpis.ac} description="Costo real incurrido" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -597,43 +335,15 @@ export function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Varianzas</h3>
|
<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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div
|
<div className={clsx('rounded-lg p-4', kpis.sv >= 0 ? 'bg-green-50' : 'bg-red-50')}>
|
||||||
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="text-sm text-gray-600">SV (Schedule Variance)</p>
|
||||||
<p
|
<p className={clsx('text-xl font-semibold mt-1', kpis.sv >= 0 ? 'text-green-700' : 'text-red-700')}>{formatCurrency(kpis.sv)}</p>
|
||||||
className={clsx(
|
<p className="text-xs text-gray-500 mt-1">{kpis.sv >= 0 ? 'Adelantado' : 'Atrasado'}</p>
|
||||||
'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>
|
||||||
<div
|
<div className={clsx('rounded-lg p-4', kpis.cv >= 0 ? 'bg-green-50' : 'bg-red-50')}>
|
||||||
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="text-sm text-gray-600">CV (Cost Variance)</p>
|
||||||
<p
|
<p className={clsx('text-xl font-semibold mt-1', kpis.cv >= 0 ? 'text-green-700' : 'text-red-700')}>{formatCurrency(kpis.cv)}</p>
|
||||||
className={clsx(
|
<p className="text-xs text-gray-500 mt-1">{kpis.cv >= 0 ? 'Bajo presupuesto' : 'Sobre presupuesto'}</p>
|
||||||
'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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -642,63 +352,26 @@ export function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Proyecciones</h3>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<EVMValueCard
|
<EVMValueCard label="BAC (Budget at Completion)" value={kpis.bac} description="Presupuesto total del proyecto" />
|
||||||
label="BAC (Budget at Completion)"
|
<EVMValueCard label="EAC (Estimate at Completion)" value={kpis.eac} description="Costo estimado final" />
|
||||||
value={kpis.bac}
|
<EVMValueCard label="ETC (Estimate to Complete)" value={kpis.etc} description="Costo estimado para terminar" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* VAC */}
|
{/* VAC */}
|
||||||
<div>
|
<div className={clsx('rounded-lg p-4', kpis.vac >= 0 ? 'bg-green-50' : 'bg-red-50')}>
|
||||||
<div
|
<div className="flex items-center justify-between">
|
||||||
className={clsx(
|
<div>
|
||||||
'rounded-lg p-4',
|
<p className="text-sm text-gray-600">VAC (Variance at Completion)</p>
|
||||||
kpis.vac >= 0 ? 'bg-green-50' : 'bg-red-50'
|
<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')}>
|
||||||
<div className="flex items-center justify-between">
|
{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-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>
|
</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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* LotesPage - Lotes Management
|
||||||
|
* Refactored to use common components
|
||||||
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Plus, Trash2, Search, RefreshCw } from 'lucide-react';
|
import { Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useLotes,
|
useLotes,
|
||||||
useLoteStats,
|
useLoteStats,
|
||||||
@ -11,18 +16,24 @@ import {
|
|||||||
useUpdateLoteStatus,
|
useUpdateLoteStatus,
|
||||||
useAssignPrototipo,
|
useAssignPrototipo,
|
||||||
} from '../../../hooks/useConstruccion';
|
} 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 { 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';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
const statusLabels: Record<LoteStatus, string> = {
|
|
||||||
available: 'Disponible',
|
|
||||||
reserved: 'Reservado',
|
|
||||||
sold: 'Vendido',
|
|
||||||
blocked: 'Bloqueado',
|
|
||||||
in_construction: 'En Construccion',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LotesPage() {
|
export function LotesPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@ -30,7 +41,7 @@ export function LotesPage() {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [showStatusModal, setShowStatusModal] = useState<string | null>(null);
|
const [showStatusModal, setShowStatusModal] = useState<string | null>(null);
|
||||||
const [showPrototipoModal, setShowPrototipoModal] = 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') || '';
|
const manzanaId = searchParams.get('manzanaId') || '';
|
||||||
|
|
||||||
@ -52,9 +63,11 @@ export function LotesPage() {
|
|||||||
const manzanas = manzanasData?.items || [];
|
const manzanas = manzanasData?.items || [];
|
||||||
const prototipos = prototiposData?.items || [];
|
const prototipos = prototiposData?.items || [];
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async () => {
|
||||||
await deleteMutation.mutateAsync(id);
|
if (deleteId) {
|
||||||
setDeleteConfirm(null);
|
await deleteMutation.mutateAsync(deleteId);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async (formData: CreateLoteDto) => {
|
const handleCreate = async (formData: CreateLoteDto) => {
|
||||||
@ -72,23 +85,38 @@ export function LotesPage() {
|
|||||||
setShowPrototipoModal(null);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<HierarchyBreadcrumb items={[{ label: 'Lotes' }]} />
|
<HierarchyBreadcrumb items={[{ label: 'Lotes' }]} />
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
<PageHeader
|
||||||
<div>
|
title="Lotes"
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Lotes</h1>
|
description="Gestion de lotes y terrenos"
|
||||||
<p className="text-gray-600">Gestion de lotes y terrenos</p>
|
actions={<PageHeaderAction onClick={() => setShowModal(true)}><Plus className="w-5 h-5 mr-2" />Nuevo Lote</PageHeaderAction>}
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
{stats && (
|
{stats && (
|
||||||
@ -98,29 +126,16 @@ export function LotesPage() {
|
|||||||
<StatCard label="Reservados" value={stats.reserved} color={getStatusColor('reserved')} />
|
<StatCard label="Reservados" value={stats.reserved} color={getStatusColor('reserved')} />
|
||||||
<StatCard label="Vendidos" value={stats.sold} color={getStatusColor('sold')} />
|
<StatCard label="Vendidos" value={stats.sold} color={getStatusColor('sold')} />
|
||||||
<StatCard label="Bloqueados" value={stats.blocked} color={getStatusColor('blocked')} />
|
<StatCard label="Bloqueados" value={stats.blocked} color={getStatusColor('blocked')} />
|
||||||
<StatCard
|
<StatCard label="En Construccion" value={stats.inConstruction} color={getStatusColor('in_construction')} />
|
||||||
label="En Construccion"
|
|
||||||
value={stats.inConstruction}
|
|
||||||
color={getStatusColor('in_construction')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
<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 flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1 relative">
|
<SearchInput value={search} onChange={setSearch} placeholder="Buscar por codigo..." className="flex-1" />
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<SelectField
|
||||||
<input
|
options={[{ value: '', label: 'Todas las manzanas' }, ...manzanaOptions]}
|
||||||
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"
|
|
||||||
value={manzanaId}
|
value={manzanaId}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.value) {
|
if (e.target.value) {
|
||||||
@ -130,112 +145,18 @@ export function LotesPage() {
|
|||||||
}
|
}
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
}}
|
}}
|
||||||
>
|
className="sm:w-56"
|
||||||
<option value="">Todas las manzanas</option>
|
/>
|
||||||
{manzanas.map((m) => (
|
<SelectField
|
||||||
<option key={m.id} value={m.id}>
|
options={[{ value: '', label: 'Todos los estados' }, ...statusOptions]}
|
||||||
{m.code} - {m.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value as LoteStatus | '')}
|
onChange={(e) => setStatusFilter(e.target.value as LoteStatus | '')}
|
||||||
>
|
className="sm:w-48"
|
||||||
<option value="">Todos los estados</option>
|
/>
|
||||||
{Object.entries(statusLabels).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
<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.' }} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
@ -268,34 +189,21 @@ export function LotesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
<ConfirmDialog
|
||||||
{deleteConfirm && (
|
isOpen={!!deleteId}
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
onClose={() => setDeleteId(null)}
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
onConfirm={handleDelete}
|
||||||
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
|
title="Confirmar eliminacion"
|
||||||
<p className="text-gray-600 mb-6">¿Esta seguro de eliminar este lote?</p>
|
message="¿Esta seguro de eliminar este lote? Esta accion no se puede deshacer."
|
||||||
<div className="flex justify-end gap-3">
|
confirmLabel="Eliminar"
|
||||||
<button
|
variant="danger"
|
||||||
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
isLoading={deleteMutation.isPending}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats Card Component (specific to lotes with color indicator)
|
||||||
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow-sm p-4">
|
<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 {
|
interface CreateLoteModalProps {
|
||||||
manzanas: { id: string; code: string; name: string }[];
|
manzanas: { id: string; code: string; name: string }[];
|
||||||
defaultManzanaId: string;
|
defaultManzanaId: string;
|
||||||
@ -316,13 +225,7 @@ interface CreateLoteModalProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateLoteModal({
|
function CreateLoteModal({ manzanas, defaultManzanaId, onClose, onSubmit, isLoading }: CreateLoteModalProps) {
|
||||||
manzanas,
|
|
||||||
defaultManzanaId,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
isLoading,
|
|
||||||
}: CreateLoteModalProps) {
|
|
||||||
const [formData, setFormData] = useState<CreateLoteDto>({
|
const [formData, setFormData] = useState<CreateLoteDto>({
|
||||||
code: '',
|
code: '',
|
||||||
manzanaId: defaultManzanaId || '',
|
manzanaId: defaultManzanaId || '',
|
||||||
@ -332,113 +235,31 @@ function CreateLoteModal({
|
|||||||
status: 'available',
|
status: 'available',
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await onSubmit(formData); };
|
||||||
e.preventDefault();
|
const update = (field: keyof CreateLoteDto, value: string | number) => setFormData({ ...formData, [field]: value });
|
||||||
await onSubmit(formData);
|
|
||||||
};
|
const manzanaOptions = manzanas.map(m => ({ value: m.id, label: `${m.code} - ${m.name}` }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal isOpen={true} onClose={onClose} title="Nuevo Lote" size="md"
|
||||||
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
|
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>}>
|
||||||
<h3 className="text-lg font-semibold mb-4">Nuevo Lote</h3>
|
<form id="lote-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
<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)} />
|
||||||
<div>
|
<FormGroup cols={2}>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Manzana *</label>
|
<TextInput label="Codigo" required value={formData.code} onChange={(e) => update('code', e.target.value)} />
|
||||||
<select
|
<TextInput label="No. Oficial" value={formData.officialNumber || ''} onChange={(e) => update('officialNumber', e.target.value)} />
|
||||||
required
|
</FormGroup>
|
||||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
<FormGroup cols={3}>
|
||||||
value={formData.manzanaId}
|
<TextInput label="Area (m2)" type="number" required step="0.01" min="0" value={formData.areaM2} onChange={(e) => update('areaM2', parseFloat(e.target.value))} />
|
||||||
onChange={(e) => setFormData({ ...formData, manzanaId: 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))} />
|
||||||
<option value="">Seleccionar manzana</option>
|
</FormGroup>
|
||||||
{manzanas.map((m) => (
|
</form>
|
||||||
<option key={m.id} value={m.id}>
|
</Modal>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status Change Modal
|
||||||
interface StatusChangeModalProps {
|
interface StatusChangeModalProps {
|
||||||
currentStatus: LoteStatus;
|
currentStatus: LoteStatus;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -450,51 +271,22 @@ function StatusChangeModal({ currentStatus, onClose, onSubmit, isLoading }: Stat
|
|||||||
const [status, setStatus] = useState<LoteStatus>(currentStatus);
|
const [status, setStatus] = useState<LoteStatus>(currentStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal isOpen={true} onClose={onClose} title="Cambiar Estado" size="sm"
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
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>}>
|
||||||
<h3 className="text-lg font-semibold mb-4">Cambiar Estado</h3>
|
<div className="space-y-3">
|
||||||
<div className="space-y-3 mb-6">
|
{LOTE_STATUS_OPTIONS.map((option) => (
|
||||||
{(Object.entries(statusLabels) as [LoteStatus, string][]).map(([value, label]) => (
|
<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')}>
|
||||||
<label
|
<input type="radio" name="status" value={option.value} checked={status === option.value} onChange={(e) => setStatus(e.target.value as LoteStatus)} className="sr-only" />
|
||||||
key={value}
|
<LoteStatusBadge status={option.value} />
|
||||||
className={clsx(
|
<span className="ml-3 text-sm text-gray-700">{option.label}</span>
|
||||||
'flex items-center p-3 border rounded-lg cursor-pointer transition-colors',
|
</label>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assign Prototipo Modal
|
||||||
interface AssignPrototipoModalProps {
|
interface AssignPrototipoModalProps {
|
||||||
prototipos: { id: string; code: string; name: string }[];
|
prototipos: { id: string; code: string; name: string }[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -502,46 +294,14 @@ interface AssignPrototipoModalProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssignPrototipoModal({
|
function AssignPrototipoModal({ prototipos, onClose, onSubmit, isLoading }: AssignPrototipoModalProps) {
|
||||||
prototipos,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
isLoading,
|
|
||||||
}: AssignPrototipoModalProps) {
|
|
||||||
const [prototipoId, setPrototipoId] = useState('');
|
const [prototipoId, setPrototipoId] = useState('');
|
||||||
|
const prototipoOptions = prototipos.map(p => ({ value: p.id, label: `${p.code} - ${p.name}` }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal isOpen={true} onClose={onClose} title="Asignar Prototipo" size="sm"
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
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>}>
|
||||||
<h3 className="text-lg font-semibold mb-4">Asignar Prototipo</h3>
|
<SelectField options={[{ value: '', label: 'Seleccionar prototipo' }, ...prototipoOptions]} value={prototipoId} onChange={(e) => setPrototipoId(e.target.value)} />
|
||||||
<select
|
</Modal>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user