Improve the production frontend
This commit is contained in:
@@ -117,7 +117,10 @@ const ModelsConfigPage: React.FC = () => {
|
||||
model,
|
||||
isTraining,
|
||||
lastTrainingDate: model?.created_at,
|
||||
accuracy: model?.training_metrics?.mape ? (100 - model.training_metrics.mape) : undefined,
|
||||
accuracy: model ?
|
||||
(model.training_metrics?.mape !== undefined ? (100 - model.training_metrics.mape) :
|
||||
(model as any).mape !== undefined ? (100 - (model as any).mape) :
|
||||
undefined) : undefined,
|
||||
status: model
|
||||
? (isTraining ? 'training' : 'active')
|
||||
: 'no_model'
|
||||
|
||||
@@ -1,145 +1,190 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Download, Clock, Users, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Calendar, Zap, Package } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { pagePresets } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package } from 'lucide-react';
|
||||
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { ProductionSchedule, BatchTracker, QualityControl } from '../../../../components/domain/production';
|
||||
import { ProductionSchedule, BatchTracker, QualityControl, CreateProductionBatchModal } from '../../../../components/domain/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import {
|
||||
useProductionDashboard,
|
||||
useActiveBatches,
|
||||
useCreateProductionBatch,
|
||||
useUpdateBatchStatus,
|
||||
productionService
|
||||
} from '../../../../api';
|
||||
import type {
|
||||
ProductionBatchResponse,
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchStatusUpdate
|
||||
} from '../../../../api';
|
||||
import {
|
||||
ProductionStatusEnum,
|
||||
ProductionPriorityEnum
|
||||
} from '../../../../api';
|
||||
import { useProductionEnums } from '../../../../utils/enumHelpers';
|
||||
|
||||
const ProductionPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('schedule');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedOrder, setSelectedOrder] = useState<typeof mockProductionOrders[0] | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedBatch, setSelectedBatch] = useState<ProductionBatchResponse | null>(null);
|
||||
const [showBatchModal, setShowBatchModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
|
||||
const mockProductionStats = {
|
||||
dailyTarget: 150,
|
||||
completed: 85,
|
||||
inProgress: 12,
|
||||
pending: 53,
|
||||
efficiency: 78,
|
||||
quality: 94,
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const productionEnums = useProductionEnums();
|
||||
|
||||
// API Data
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading: dashboardLoading,
|
||||
error: dashboardError
|
||||
} = useProductionDashboard(tenantId);
|
||||
|
||||
const {
|
||||
data: activeBatchesData,
|
||||
isLoading: batchesLoading,
|
||||
error: batchesError
|
||||
} = useActiveBatches(tenantId);
|
||||
|
||||
// Mutations
|
||||
const createBatchMutation = useCreateProductionBatch();
|
||||
const updateBatchStatusMutation = useUpdateBatchStatus();
|
||||
|
||||
// Handlers
|
||||
const handleCreateBatch = async (batchData: ProductionBatchCreate) => {
|
||||
try {
|
||||
await createBatchMutation.mutateAsync({
|
||||
tenantId,
|
||||
batchData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating production batch:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const mockProductionOrders = [
|
||||
{
|
||||
id: '1',
|
||||
recipeName: 'Pan de Molde Integral',
|
||||
quantity: 20,
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
assignedTo: 'Juan Panadero',
|
||||
startTime: '2024-01-26T06:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T10:00:00Z',
|
||||
progress: 65,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
recipeName: 'Croissants de Mantequilla',
|
||||
quantity: 50,
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
assignedTo: 'María González',
|
||||
startTime: '2024-01-26T08:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T12:00:00Z',
|
||||
progress: 0,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
recipeName: 'Baguettes Francesas',
|
||||
quantity: 30,
|
||||
status: 'completed',
|
||||
priority: 'medium',
|
||||
assignedTo: 'Carlos Ruiz',
|
||||
startTime: '2024-01-26T04:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T08:00:00Z',
|
||||
progress: 100,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
recipeName: 'Tarta de Chocolate',
|
||||
quantity: 5,
|
||||
status: 'pending',
|
||||
priority: 'low',
|
||||
assignedTo: 'Ana Pastelera',
|
||||
startTime: '2024-01-26T10:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T16:00:00Z',
|
||||
progress: 0,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
recipeName: 'Empanadas de Pollo',
|
||||
quantity: 40,
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
assignedTo: 'Luis Hornero',
|
||||
startTime: '2024-01-26T07:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T11:00:00Z',
|
||||
progress: 45,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
recipeName: 'Donuts Glaseados',
|
||||
quantity: 60,
|
||||
status: 'pending',
|
||||
priority: 'urgent',
|
||||
assignedTo: 'María González',
|
||||
startTime: '2024-01-26T12:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T15:00:00Z',
|
||||
progress: 0,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
recipeName: 'Pan de Centeno',
|
||||
quantity: 25,
|
||||
status: 'completed',
|
||||
priority: 'medium',
|
||||
assignedTo: 'Juan Panadero',
|
||||
startTime: '2024-01-26T05:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T09:00:00Z',
|
||||
progress: 100,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
recipeName: 'Muffins de Arándanos',
|
||||
quantity: 36,
|
||||
status: 'in_progress',
|
||||
priority: 'medium',
|
||||
assignedTo: 'Ana Pastelera',
|
||||
startTime: '2024-01-26T08:30:00Z',
|
||||
estimatedCompletion: '2024-01-26T12:30:00Z',
|
||||
progress: 70,
|
||||
},
|
||||
];
|
||||
|
||||
const getProductionStatusConfig = (status: string, priority: string) => {
|
||||
const getProductionStatusConfig = (status: ProductionStatusEnum, priority: ProductionPriorityEnum) => {
|
||||
const statusConfig = {
|
||||
pending: { text: 'Pendiente', icon: Clock },
|
||||
in_progress: { text: 'En Proceso', icon: Timer },
|
||||
completed: { text: 'Completado', icon: CheckCircle },
|
||||
cancelled: { text: 'Cancelado', icon: AlertCircle },
|
||||
[ProductionStatusEnum.PENDING]: { icon: Clock },
|
||||
[ProductionStatusEnum.IN_PROGRESS]: { icon: Timer },
|
||||
[ProductionStatusEnum.COMPLETED]: { icon: CheckCircle },
|
||||
[ProductionStatusEnum.CANCELLED]: { icon: AlertCircle },
|
||||
[ProductionStatusEnum.ON_HOLD]: { icon: AlertCircle },
|
||||
[ProductionStatusEnum.QUALITY_CHECK]: { icon: Package },
|
||||
[ProductionStatusEnum.FAILED]: { icon: AlertCircle },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
const Icon = config?.icon;
|
||||
const isUrgent = priority === 'urgent';
|
||||
|
||||
|
||||
const config = statusConfig[status] || { icon: AlertCircle };
|
||||
const Icon = config.icon;
|
||||
const isUrgent = priority === ProductionPriorityEnum.URGENT;
|
||||
const isCritical = status === ProductionStatusEnum.FAILED || (status === ProductionStatusEnum.PENDING && isUrgent);
|
||||
|
||||
return {
|
||||
color: getStatusColor(status),
|
||||
text: config?.text || status,
|
||||
color: getStatusColor(
|
||||
status === ProductionStatusEnum.COMPLETED ? 'completed' :
|
||||
status === ProductionStatusEnum.PENDING ? 'pending' :
|
||||
status === ProductionStatusEnum.CANCELLED || status === ProductionStatusEnum.FAILED ? 'cancelled' :
|
||||
'in_progress'
|
||||
),
|
||||
text: productionEnums.getProductionStatusLabel(status),
|
||||
icon: Icon,
|
||||
isCritical: isUrgent,
|
||||
isHighlight: false
|
||||
isCritical,
|
||||
isHighlight: isUrgent
|
||||
};
|
||||
};
|
||||
|
||||
const filteredOrders = mockProductionOrders.filter(order => {
|
||||
const matchesSearch = order.recipeName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
order.assignedTo.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
order.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
const batches = activeBatchesData?.batches || [];
|
||||
|
||||
const filteredBatches = useMemo(() => {
|
||||
if (!searchQuery) return batches;
|
||||
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return batches.filter(batch =>
|
||||
batch.product_name.toLowerCase().includes(searchLower) ||
|
||||
batch.batch_number.toLowerCase().includes(searchLower) ||
|
||||
(batch.staff_assigned && batch.staff_assigned.some(staff =>
|
||||
staff.toLowerCase().includes(searchLower)
|
||||
))
|
||||
);
|
||||
}, [batches, searchQuery]);
|
||||
|
||||
// Calculate production stats from real data
|
||||
const productionStats = useMemo(() => {
|
||||
if (!dashboardData) {
|
||||
return {
|
||||
activeBatches: 0,
|
||||
todaysTarget: 0,
|
||||
capacityUtilization: 0,
|
||||
onTimeCompletion: 0,
|
||||
qualityScore: 0,
|
||||
totalOutput: 0,
|
||||
efficiency: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
activeBatches: dashboardData.active_batches || 0,
|
||||
todaysTarget: dashboardData.todays_production_plan?.length || 0,
|
||||
capacityUtilization: Math.round(dashboardData.capacity_utilization || 0),
|
||||
onTimeCompletion: Math.round(dashboardData.on_time_completion_rate || 0),
|
||||
qualityScore: Math.round(dashboardData.average_quality_score || 0),
|
||||
totalOutput: dashboardData.total_output_today || 0,
|
||||
efficiency: Math.round(dashboardData.efficiency_percentage || 0)
|
||||
};
|
||||
}, [dashboardData]);
|
||||
|
||||
// Calculate progress for batches
|
||||
const calculateProgress = (batch: ProductionBatchResponse): number => {
|
||||
if (batch.status === 'completed') return 100;
|
||||
if (batch.status === 'pending') return 0;
|
||||
if (batch.status === 'cancelled' || batch.status === 'failed') return 0;
|
||||
|
||||
// For in-progress batches, calculate based on time elapsed
|
||||
if (batch.actual_start_time && batch.planned_end_time) {
|
||||
const now = new Date();
|
||||
const startTime = new Date(batch.actual_start_time);
|
||||
const endTime = new Date(batch.planned_end_time);
|
||||
const totalDuration = endTime.getTime() - startTime.getTime();
|
||||
const elapsed = now.getTime() - startTime.getTime();
|
||||
|
||||
if (totalDuration > 0) {
|
||||
return Math.min(90, Math.max(10, Math.round((elapsed / totalDuration) * 100)));
|
||||
}
|
||||
}
|
||||
|
||||
// Default progress for in-progress items
|
||||
return 50;
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (!tenantId || dashboardLoading || batchesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-64">
|
||||
<LoadingSpinner text="Cargando producción..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (dashboardError || batchesError) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
Error al cargar la producción
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{(dashboardError || batchesError)?.message || 'Ha ocurrido un error inesperado'}
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -147,26 +192,56 @@ const ProductionPage: React.FC = () => {
|
||||
title="Gestión de Producción"
|
||||
description="Planifica y controla la producción diaria de tu panadería"
|
||||
actions={[
|
||||
{
|
||||
id: "export",
|
||||
label: "Exportar",
|
||||
variant: "outline" as const,
|
||||
icon: Download,
|
||||
onClick: () => console.log('Export production orders')
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
label: "Nueva Orden de Producción",
|
||||
variant: "primary" as const,
|
||||
icon: Plus,
|
||||
onClick: () => setShowForm(true)
|
||||
onClick: () => setShowCreateModal(true)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Production Stats */}
|
||||
<StatsGrid
|
||||
stats={pagePresets.production(mockProductionStats)}
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
title: 'Lotes Activos',
|
||||
value: productionStats.activeBatches,
|
||||
variant: 'default' as const,
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Utilización Capacidad',
|
||||
value: `${productionStats.capacityUtilization}%`,
|
||||
variant: productionStats.capacityUtilization >= 80 ? 'success' as const : 'warning' as const,
|
||||
icon: Timer,
|
||||
},
|
||||
{
|
||||
title: 'Completado a Tiempo',
|
||||
value: `${productionStats.onTimeCompletion}%`,
|
||||
variant: productionStats.onTimeCompletion >= 90 ? 'success' as const : 'error' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
title: 'Puntuación Calidad',
|
||||
value: `${productionStats.qualityScore}%`,
|
||||
variant: productionStats.qualityScore >= 85 ? 'success' as const : 'warning' as const,
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Producción Hoy',
|
||||
value: formatters.number(productionStats.totalOutput),
|
||||
variant: 'info' as const,
|
||||
icon: ChefHat,
|
||||
},
|
||||
{
|
||||
title: 'Eficiencia',
|
||||
value: `${productionStats.efficiency}%`,
|
||||
variant: productionStats.efficiency >= 75 ? 'success' as const : 'warning' as const,
|
||||
icon: Timer,
|
||||
},
|
||||
]}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
@@ -209,66 +284,64 @@ const ProductionPage: React.FC = () => {
|
||||
{/* Production Orders Tab */}
|
||||
{activeTab === 'schedule' && (
|
||||
<>
|
||||
{/* Simplified Controls */}
|
||||
{/* Search Controls */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Buscar órdenes por receta, asignado o ID..."
|
||||
placeholder="Buscar lotes por producto, número de lote o personal..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Production Orders Grid */}
|
||||
{/* Production Batches Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredOrders.map((order) => {
|
||||
const statusConfig = getProductionStatusConfig(order.status, order.priority);
|
||||
|
||||
{filteredBatches.map((batch) => {
|
||||
const statusConfig = getProductionStatusConfig(batch.status, batch.priority);
|
||||
const progress = calculateProgress(batch);
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={order.id}
|
||||
id={order.id}
|
||||
key={batch.id}
|
||||
id={batch.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={order.recipeName}
|
||||
subtitle={`Asignado a: ${order.assignedTo}`}
|
||||
primaryValue={order.quantity}
|
||||
title={batch.product_name}
|
||||
subtitle={`Lote: ${batch.batch_number}`}
|
||||
primaryValue={batch.planned_quantity}
|
||||
primaryValueLabel="unidades"
|
||||
secondaryInfo={{
|
||||
label: 'Horario',
|
||||
value: `${new Date(order.startTime).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })} → ${new Date(order.estimatedCompletion).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })}`
|
||||
label: 'Personal',
|
||||
value: batch.staff_assigned?.join(', ') || 'No asignado'
|
||||
}}
|
||||
progress={{
|
||||
label: 'Progreso',
|
||||
percentage: order.progress,
|
||||
percentage: progress,
|
||||
color: statusConfig.color
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
label: 'Ver',
|
||||
label: 'Ver Detalles',
|
||||
icon: Eye,
|
||||
variant: 'outline',
|
||||
variant: 'primary',
|
||||
priority: 'primary',
|
||||
onClick: () => {
|
||||
setSelectedOrder(order);
|
||||
setSelectedBatch(batch);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
setShowBatchModal(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Editar',
|
||||
icon: Edit,
|
||||
variant: 'outline',
|
||||
priority: 'secondary',
|
||||
onClick: () => {
|
||||
setSelectedOrder(order);
|
||||
setSelectedBatch(batch);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
setShowBatchModal(true);
|
||||
}
|
||||
}
|
||||
]}
|
||||
@@ -278,16 +351,19 @@ const ProductionPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredOrders.length === 0 && (
|
||||
{filteredBatches.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<ChefHat className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron órdenes de producción
|
||||
No se encontraron lotes de producción
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Intenta ajustar la búsqueda o crear una nueva orden de producción
|
||||
{batches.length === 0
|
||||
? 'No hay lotes de producción activos. Crea el primer lote para comenzar.'
|
||||
: 'Intenta ajustar la búsqueda o crear un nuevo lote de producción'
|
||||
}
|
||||
</p>
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Orden de Producción
|
||||
</Button>
|
||||
@@ -304,20 +380,20 @@ const ProductionPage: React.FC = () => {
|
||||
<QualityControl />
|
||||
)}
|
||||
|
||||
{/* Production Order Modal */}
|
||||
{showForm && selectedOrder && (
|
||||
{/* Production Batch Modal */}
|
||||
{showBatchModal && selectedBatch && (
|
||||
<StatusModal
|
||||
isOpen={showForm}
|
||||
isOpen={showBatchModal}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedOrder(null);
|
||||
setShowBatchModal(false);
|
||||
setSelectedBatch(null);
|
||||
setModalMode('view');
|
||||
}}
|
||||
mode={modalMode}
|
||||
onModeChange={setModalMode}
|
||||
title={selectedOrder.recipeName}
|
||||
subtitle={`Orden de Producción #${selectedOrder.id}`}
|
||||
statusIndicator={getProductionStatusConfig(selectedOrder.status, selectedOrder.priority)}
|
||||
title={selectedBatch.product_name}
|
||||
subtitle={`Lote de Producción #${selectedBatch.batch_number}`}
|
||||
statusIndicator={getProductionStatusConfig(selectedBatch.status, selectedBatch.priority)}
|
||||
size="lg"
|
||||
sections={[
|
||||
{
|
||||
@@ -325,24 +401,37 @@ const ProductionPage: React.FC = () => {
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Cantidad',
|
||||
value: `${selectedOrder.quantity} unidades`,
|
||||
label: 'Cantidad Planificada',
|
||||
value: `${selectedBatch.planned_quantity} unidades`,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Asignado a',
|
||||
value: selectedOrder.assignedTo,
|
||||
label: 'Cantidad Real',
|
||||
value: selectedBatch.actual_quantity
|
||||
? `${selectedBatch.actual_quantity} unidades`
|
||||
: 'Pendiente',
|
||||
editable: modalMode === 'edit',
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
label: 'Prioridad',
|
||||
value: selectedOrder.priority,
|
||||
type: 'status'
|
||||
value: selectedBatch.priority,
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: productionEnums.getProductionPriorityOptions()
|
||||
},
|
||||
{
|
||||
label: 'Progreso',
|
||||
value: selectedOrder.progress,
|
||||
type: 'percentage',
|
||||
highlight: true
|
||||
label: 'Estado',
|
||||
value: selectedBatch.status,
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: productionEnums.getProductionStatusOptions()
|
||||
},
|
||||
{
|
||||
label: 'Personal Asignado',
|
||||
value: selectedBatch.staff_assigned?.join(', ') || 'No asignado',
|
||||
editable: modalMode === 'edit',
|
||||
type: 'text'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -351,24 +440,128 @@ const ProductionPage: React.FC = () => {
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Hora de inicio',
|
||||
value: selectedOrder.startTime,
|
||||
label: 'Inicio Planificado',
|
||||
value: selectedBatch.planned_start_time,
|
||||
type: 'datetime'
|
||||
},
|
||||
{
|
||||
label: 'Finalización estimada',
|
||||
value: selectedOrder.estimatedCompletion,
|
||||
label: 'Fin Planificado',
|
||||
value: selectedBatch.planned_end_time,
|
||||
type: 'datetime'
|
||||
},
|
||||
{
|
||||
label: 'Inicio Real',
|
||||
value: selectedBatch.actual_start_time || 'Pendiente',
|
||||
type: 'datetime'
|
||||
},
|
||||
{
|
||||
label: 'Fin Real',
|
||||
value: selectedBatch.actual_end_time || 'Pendiente',
|
||||
type: 'datetime'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Calidad y Costos',
|
||||
icon: CheckCircle,
|
||||
fields: [
|
||||
{
|
||||
label: 'Puntuación de Calidad',
|
||||
value: selectedBatch.quality_score
|
||||
? `${selectedBatch.quality_score}/10`
|
||||
: 'Pendiente'
|
||||
},
|
||||
{
|
||||
label: 'Rendimiento',
|
||||
value: selectedBatch.yield_percentage
|
||||
? `${selectedBatch.yield_percentage}%`
|
||||
: 'Calculando...'
|
||||
},
|
||||
{
|
||||
label: 'Costo Estimado',
|
||||
value: selectedBatch.estimated_cost || 0,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Costo Real',
|
||||
value: selectedBatch.actual_cost || 0,
|
||||
type: 'currency'
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
onEdit={() => {
|
||||
// Handle edit mode
|
||||
console.log('Editing production order:', selectedOrder.id);
|
||||
onSave={async () => {
|
||||
try {
|
||||
// Implementation would depend on specific fields changed
|
||||
console.log('Saving batch changes:', selectedBatch.id);
|
||||
// await updateBatchStatusMutation.mutateAsync({
|
||||
// batchId: selectedBatch.id,
|
||||
// updates: selectedBatch
|
||||
// });
|
||||
setShowBatchModal(false);
|
||||
setSelectedBatch(null);
|
||||
setModalMode('view');
|
||||
} catch (error) {
|
||||
console.error('Error saving batch:', error);
|
||||
}
|
||||
}}
|
||||
onFieldChange={(sectionIndex, fieldIndex, value) => {
|
||||
if (!selectedBatch) return;
|
||||
|
||||
const sections = [
|
||||
['planned_quantity', 'actual_quantity', 'priority', 'status', 'staff_assigned'],
|
||||
['planned_start_time', 'planned_end_time', 'actual_start_time', 'actual_end_time'],
|
||||
['quality_score', 'yield_percentage', 'estimated_cost', 'actual_cost']
|
||||
];
|
||||
|
||||
// Get the field names from modal sections
|
||||
const sectionFields = [
|
||||
{ fields: ['planned_quantity', 'actual_quantity', 'priority', 'status', 'staff_assigned'] },
|
||||
{ fields: ['planned_start_time', 'planned_end_time', 'actual_start_time', 'actual_end_time'] },
|
||||
{ fields: ['quality_score', 'yield_percentage', 'estimated_cost', 'actual_cost'] }
|
||||
];
|
||||
|
||||
const fieldMapping: Record<string, string> = {
|
||||
'Cantidad Real': 'actual_quantity',
|
||||
'Prioridad': 'priority',
|
||||
'Estado': 'status',
|
||||
'Personal Asignado': 'staff_assigned'
|
||||
};
|
||||
|
||||
// Get section labels to map back to field names
|
||||
const sectionLabels = [
|
||||
['Cantidad Planificada', 'Cantidad Real', 'Prioridad', 'Estado', 'Personal Asignado'],
|
||||
['Inicio Planificado', 'Fin Planificado', 'Inicio Real', 'Fin Real'],
|
||||
['Puntuación de Calidad', 'Rendimiento', 'Costo Estimado', 'Costo Real']
|
||||
];
|
||||
|
||||
const fieldLabel = sectionLabels[sectionIndex]?.[fieldIndex];
|
||||
const propertyName = fieldMapping[fieldLabel] || sectionFields[sectionIndex]?.fields[fieldIndex];
|
||||
|
||||
if (propertyName) {
|
||||
let processedValue: any = value;
|
||||
|
||||
if (propertyName === 'staff_assigned' && typeof value === 'string') {
|
||||
processedValue = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
} else if (propertyName === 'actual_quantity') {
|
||||
processedValue = parseFloat(value as string) || 0;
|
||||
}
|
||||
|
||||
setSelectedBatch({
|
||||
...selectedBatch,
|
||||
[propertyName]: processedValue
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Production Batch Modal */}
|
||||
<CreateProductionBatchModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreateBatch={handleCreateBatch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Users, Plus, Search, Mail, Phone, Shield, Trash2, Crown, X, UserCheck } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { Button, Card, Badge, Input, StatusCard, getStatusColor, StatsGrid } from '../../../../components/ui';
|
||||
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole } from '../../../../api/hooks/tenant';
|
||||
import { useAllUsers } from '../../../../api/hooks/user';
|
||||
@@ -285,55 +286,36 @@ const TeamPage: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* Team Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Equipo</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">{teamStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-[var(--color-info)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Activos</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-success)]">{teamStats.active}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||
<UserCheck className="h-6 w-6 text-[var(--color-success)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Administradores</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{teamStats.admins}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||
<Shield className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Propietarios</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{teamStats.owners}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Crown className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
title: "Total Equipo",
|
||||
value: teamStats.total,
|
||||
icon: Users,
|
||||
variant: "info"
|
||||
},
|
||||
{
|
||||
title: "Activos",
|
||||
value: teamStats.active,
|
||||
icon: UserCheck,
|
||||
variant: "success"
|
||||
},
|
||||
{
|
||||
title: "Administradores",
|
||||
value: teamStats.admins,
|
||||
icon: Shield,
|
||||
variant: "info"
|
||||
},
|
||||
{
|
||||
title: "Propietarios",
|
||||
value: teamStats.owners,
|
||||
icon: Crown,
|
||||
variant: "purple"
|
||||
}
|
||||
]}
|
||||
columns={4}
|
||||
gap="md"
|
||||
/>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
@@ -368,6 +350,21 @@ const TeamPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Add Member Button */}
|
||||
{canManageTeam && availableUsers.length > 0 && filteredMembers.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="font-medium px-4 py-2 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span>Agregar Miembro</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Members List - Responsive grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||
{filteredMembers.map((member) => (
|
||||
@@ -398,124 +395,58 @@ const TeamPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
<StatusCard
|
||||
id="empty-state"
|
||||
statusIndicator={{
|
||||
color: getStatusColor('pending'),
|
||||
text: searchTerm || selectedRole !== 'all' ? 'Sin coincidencias' : 'Equipo vacío',
|
||||
icon: Users,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
}}
|
||||
title="No se encontraron miembros"
|
||||
subtitle={searchTerm || selectedRole !== 'all'
|
||||
? "No hay miembros que coincidan con los filtros seleccionados"
|
||||
: "Este tenant aún no tiene miembros del equipo"
|
||||
}
|
||||
primaryValue="0"
|
||||
primaryValueLabel="Miembros"
|
||||
actions={canManageTeam && availableUsers.length > 0 ? [{
|
||||
label: 'Agregar Primer Miembro',
|
||||
icon: Plus,
|
||||
onClick: () => setShowAddForm(true),
|
||||
priority: 'primary' as const,
|
||||
}] : []}
|
||||
className="col-span-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Member Modal */}
|
||||
{showAddForm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Agregar Miembro al Equipo</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* User Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Usuario
|
||||
</label>
|
||||
<select
|
||||
value={selectedUserToAdd}
|
||||
onChange={(e) => setSelectedUserToAdd(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border-secondary rounded-lg bg-bg-primary focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20"
|
||||
required
|
||||
>
|
||||
<option value="">Seleccionar usuario...</option>
|
||||
{availableUsers.map(user => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.full_name} ({user.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{availableUsers.length === 0 && (
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
No hay usuarios disponibles para agregar
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Rol
|
||||
</label>
|
||||
<select
|
||||
value={selectedRoleToAdd}
|
||||
onChange={(e) => setSelectedRoleToAdd(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border-secondary rounded-lg bg-bg-primary focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20"
|
||||
>
|
||||
<option value={TENANT_ROLES.MEMBER}>Miembro - Acceso estándar</option>
|
||||
<option value={TENANT_ROLES.ADMIN}>Administrador - Gestión de equipo</option>
|
||||
<option value={TENANT_ROLES.VIEWER}>Observador - Solo lectura</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Role Description */}
|
||||
<div className="p-3 bg-bg-secondary rounded-lg">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{selectedRoleToAdd === TENANT_ROLES.ADMIN &&
|
||||
'Los administradores pueden gestionar miembros del equipo y configuraciones.'}
|
||||
{selectedRoleToAdd === TENANT_ROLES.MEMBER &&
|
||||
'Los miembros tienen acceso completo para trabajar con datos y funcionalidades.'}
|
||||
{selectedRoleToAdd === TENANT_ROLES.VIEWER &&
|
||||
'Los observadores solo pueden ver datos, sin realizar cambios.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mt-6">
|
||||
<Button
|
||||
onClick={handleAddMember}
|
||||
disabled={!selectedUserToAdd || addMemberMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{addMemberMutation.isPending ? 'Agregando...' : 'Agregar Miembro'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setSelectedUserToAdd('');
|
||||
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="text-center py-12">
|
||||
<Users className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No se encontraron miembros
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{searchTerm || selectedRole !== 'all'
|
||||
? "No hay miembros que coincidan con los filtros seleccionados"
|
||||
: "Este tenant aún no tiene miembros del equipo"
|
||||
}
|
||||
</p>
|
||||
{canManageTeam && availableUsers.length > 0 && (
|
||||
<Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="font-medium px-6 py-3 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span>Agregar Primer Miembro</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Member Modal - Using StatusModal */}
|
||||
<AddTeamMemberModal
|
||||
isOpen={showAddForm}
|
||||
onClose={() => {
|
||||
setShowAddForm(false);
|
||||
setSelectedUserToAdd('');
|
||||
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
|
||||
}}
|
||||
onAddMember={async (userData) => {
|
||||
if (!tenantId) return Promise.reject('No tenant ID available');
|
||||
|
||||
return addMemberMutation.mutateAsync({
|
||||
tenantId,
|
||||
userId: userData.userId,
|
||||
role: userData.role,
|
||||
}).then(() => {
|
||||
addToast('Miembro agregado exitosamente', { type: 'success' });
|
||||
setShowAddForm(false);
|
||||
setSelectedUserToAdd('');
|
||||
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
|
||||
}).catch((error) => {
|
||||
addToast('Error al agregar miembro', { type: 'error' });
|
||||
throw error;
|
||||
});
|
||||
}}
|
||||
availableUsers={availableUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user