Improve the frontend modals

This commit is contained in:
Urtzi Alfaro
2025-10-27 16:33:26 +01:00
parent 61376b7a9f
commit 858d985c92
143 changed files with 9289 additions and 2306 deletions

View File

@@ -6,7 +6,7 @@ import StatsGrid from '../../components/ui/Stats/StatsGrid';
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
import PendingPOApprovals from '../../components/domain/dashboard/PendingPOApprovals';
import TodayProduction from '../../components/domain/dashboard/TodayProduction';
import SustainabilityWidget from '../../components/domain/sustainability/SustainabilityWidget';
// Sustainability widget removed - now using stats in StatsGrid
import { EditViewModal } from '../../components/ui';
import { useTenant } from '../../stores/tenant.store';
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
@@ -26,7 +26,9 @@ import {
X,
ShoppingCart,
Factory,
Timer
Timer,
TrendingDown,
Leaf
} from 'lucide-react';
import toast from 'react-hot-toast';
@@ -259,6 +261,28 @@ const DashboardPage: React.FC = () => {
subtitle: dashboardStats.criticalStock > 0
? t('dashboard:messages.action_required', 'Action required')
: t('dashboard:messages.stock_healthy', 'Stock levels healthy')
},
{
title: t('dashboard:stats.waste_reduction', 'Waste Reduction'),
value: dashboardStats.wasteReductionPercentage
? `${Math.abs(dashboardStats.wasteReductionPercentage).toFixed(1)}%`
: '0%',
icon: TrendingDown,
variant: (dashboardStats.wasteReductionPercentage || 0) >= 15 ? ('success' as const) : ('info' as const),
trend: undefined,
subtitle: (dashboardStats.wasteReductionPercentage || 0) >= 15
? t('dashboard:messages.excellent_progress', 'Excellent progress!')
: t('dashboard:messages.keep_improving', 'Keep improving')
},
{
title: t('dashboard:stats.monthly_savings', 'Monthly Savings'),
value: dashboardStats.monthlySavingsEur
? `${dashboardStats.monthlySavingsEur.toFixed(0)}`
: '€0',
icon: Leaf,
variant: 'success' as const,
trend: undefined,
subtitle: t('dashboard:messages.from_sustainability', 'From sustainability')
}
];
}, [dashboardStats, t]);
@@ -382,8 +406,8 @@ const DashboardPage: React.FC = () => {
{/* Critical Metrics using StatsGrid */}
<div data-tour="dashboard-stats">
{isLoadingStats ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[1, 2, 3, 4].map((i) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="h-32 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg animate-pulse"
@@ -399,7 +423,7 @@ const DashboardPage: React.FC = () => {
) : (
<StatsGrid
stats={criticalStats}
columns={4}
columns={6}
gap="lg"
className="mb-6"
/>
@@ -413,19 +437,7 @@ const DashboardPage: React.FC = () => {
<RealTimeAlerts />
</div>
{/* 2. Sustainability Impact - NEW! */}
<div data-tour="sustainability-widget">
<SustainabilityWidget
days={30}
onViewDetails={() => navigate('/app/analytics/sustainability')}
onExportReport={() => {
// TODO: Implement export modal
console.log('Export sustainability report');
}}
/>
</div>
{/* 3. Pending PO Approvals - What purchase orders need approval? */}
{/* 2. Pending PO Approvals - What purchase orders need approval? */}
<div data-tour="pending-po-approvals">
<PendingPOApprovals
onApprovePO={handleApprovePO}
@@ -436,7 +448,7 @@ const DashboardPage: React.FC = () => {
/>
</div>
{/* 4. Today's Production - What needs to be produced today? */}
{/* 3. Today's Production - What needs to be produced today? */}
<div data-tour="today-production">
<TodayProduction
onStartBatch={handleStartBatch}

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Brain, TrendingUp, AlertTriangle, Lightbulb, Target, Zap, Download, RefreshCw } from 'lucide-react';
import { Button, Card, Badge } from '../../../../components/ui';
import { Button, Card, Badge, StatsGrid } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
const AIInsightsPage: React.FC = () => {
@@ -109,6 +109,8 @@ const AIInsightsPage: React.FC = () => {
actionableInsights: insights.filter(i => i.actionable).length,
averageConfidence: Math.round(insights.reduce((sum, i) => sum + i.confidence, 0) / insights.length),
highPriorityInsights: insights.filter(i => i.priority === 'high').length,
mediumPriorityInsights: insights.filter(i => i.priority === 'medium').length,
lowPriorityInsights: insights.filter(i => i.priority === 'low').length,
};
const getTypeIcon = (type: string) => {
@@ -125,10 +127,10 @@ const AIInsightsPage: React.FC = () => {
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'red';
case 'medium': return 'yellow';
case 'low': return 'green';
default: return 'gray';
case 'high': return 'error';
case 'medium': return 'warning';
case 'low': return 'success';
default: return 'default';
}
};
@@ -173,55 +175,47 @@ const AIInsightsPage: React.FC = () => {
/>
{/* AI Metrics */}
<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 Insights</p>
<p className="text-3xl font-bold text-[var(--color-info)]">{aiMetrics.totalInsights}</p>
</div>
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
<Brain 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)]">Accionables</p>
<p className="text-3xl font-bold text-[var(--color-success)]">{aiMetrics.actionableInsights}</p>
</div>
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
<Zap 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)]">Confianza Promedio</p>
<p className="text-3xl font-bold text-purple-600">{aiMetrics.averageConfidence}%</p>
</div>
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
<Target className="h-6 w-6 text-purple-600" />
</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)]">Alta Prioridad</p>
<p className="text-3xl font-bold text-[var(--color-error)]">{aiMetrics.highPriorityInsights}</p>
</div>
<div className="h-12 w-12 bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
<AlertTriangle className="h-6 w-6 text-[var(--color-error)]" />
</div>
</div>
</Card>
</div>
<StatsGrid
stats={[
{
title: "Total Insights",
value: aiMetrics.totalInsights,
icon: Brain,
variant: "info"
},
{
title: "Accionables",
value: aiMetrics.actionableInsights,
icon: Zap,
variant: "success"
},
{
title: "Confianza Promedio",
value: `${aiMetrics.averageConfidence}%`,
icon: Target,
variant: "info"
},
{
title: "Alta Prioridad",
value: aiMetrics.highPriorityInsights,
icon: AlertTriangle,
variant: "error"
},
{
title: "Media Prioridad",
value: aiMetrics.mediumPriorityInsights,
icon: TrendingUp,
variant: "warning"
},
{
title: "Baja Prioridad",
value: aiMetrics.lowPriorityInsights,
icon: Lightbulb,
variant: "success"
}
]}
columns={3}
/>
{/* Category Filter */}
<Card className="p-6">
@@ -256,9 +250,9 @@ const AIInsightsPage: React.FC = () => {
<Badge variant={getPriorityColor(insight.priority)}>
{insight.priority === 'high' ? 'Alta' : insight.priority === 'medium' ? 'Media' : 'Baja'} Prioridad
</Badge>
<Badge variant="gray">{insight.confidence}% confianza</Badge>
<Badge variant="secondary">{insight.confidence}% confianza</Badge>
{insight.actionable && (
<Badge variant="blue">Accionable</Badge>
<Badge variant="primary">Accionable</Badge>
)}
</div>
</div>
@@ -310,4 +304,4 @@ const AIInsightsPage: React.FC = () => {
);
};
export default AIInsightsPage;
export default AIInsightsPage;

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { ShoppingCart, TrendingUp, Clock, AlertTriangle } from 'lucide-react';
import { ShoppingCart, TrendingUp, Clock, AlertTriangle, Brain } from 'lucide-react';
import { Card, Input } from '../../../../../components/ui';
import type { ProcurementSettings } from '../../../../../api/types/settings';
import { useTranslation } from 'react-i18next';
interface ProcurementSettingsCardProps {
settings: ProcurementSettings;
@@ -14,6 +15,8 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
onChange,
disabled = false,
}) => {
const { t } = useTranslation('ajustes');
const handleChange = (field: keyof ProcurementSettings) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
@@ -27,7 +30,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-6 flex items-center">
<ShoppingCart className="w-5 h-5 mr-2 text-[var(--color-primary)]" />
Compras y Aprovisionamiento
{t('procurement.title')}
</h3>
<div className="space-y-6">
@@ -35,7 +38,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
<div>
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 flex items-center">
<TrendingUp className="w-4 h-4 mr-2" />
Auto-Aprobación de Órdenes de Compra
{t('procurement.auto_approval')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 pl-6">
<div className="flex items-center gap-2 md:col-span-2 xl:col-span-3">
@@ -48,13 +51,13 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="auto_approve_enabled" className="text-sm text-[var(--text-secondary)]">
Habilitar auto-aprobación de órdenes de compra
{t('procurement.auto_approve_enabled')}
</label>
</div>
<Input
type="number"
label="Umbral de Auto-Aprobación (EUR)"
label={t('procurement.auto_approve_threshold')}
value={settings.auto_approve_threshold_eur}
onChange={handleChange('auto_approve_threshold_eur')}
disabled={disabled || !settings.auto_approve_enabled}
@@ -66,7 +69,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
<Input
type="number"
label="Puntuación Mínima de Proveedor"
label={t('procurement.min_supplier_score')}
value={settings.auto_approve_min_supplier_score}
onChange={handleChange('auto_approve_min_supplier_score')}
disabled={disabled || !settings.auto_approve_enabled}
@@ -86,7 +89,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="require_approval_new_suppliers" className="text-sm text-[var(--text-secondary)]">
Requiere aprobación para nuevos proveedores
{t('procurement.require_approval_new_suppliers')}
</label>
</div>
@@ -100,7 +103,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="require_approval_critical_items" className="text-sm text-[var(--text-secondary)]">
Requiere aprobación para artículos críticos
{t('procurement.require_approval_critical_items')}
</label>
</div>
</div>
@@ -110,12 +113,12 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
<div>
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 flex items-center">
<Clock className="w-4 h-4 mr-2" />
Planificación y Previsión
{t('procurement.planning')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 pl-6">
<Input
type="number"
label="Tiempo de Entrega (días)"
label={t('procurement.lead_time_days')}
value={settings.procurement_lead_time_days}
onChange={handleChange('procurement_lead_time_days')}
disabled={disabled}
@@ -127,7 +130,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
<Input
type="number"
label="Días de Previsión de Demanda"
label={t('procurement.demand_forecast_days')}
value={settings.demand_forecast_days}
onChange={handleChange('demand_forecast_days')}
disabled={disabled}
@@ -139,7 +142,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
<Input
type="number"
label="Stock de Seguridad (%)"
label={t('procurement.safety_stock_percentage')}
value={settings.safety_stock_percentage}
onChange={handleChange('safety_stock_percentage')}
disabled={disabled}
@@ -155,12 +158,12 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
<div>
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 flex items-center">
<AlertTriangle className="w-4 h-4 mr-2" />
Flujo de Aprobación
{t('procurement.workflow')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pl-6">
<Input
type="number"
label="Recordatorio de Aprobación (horas)"
label={t('procurement.approval_reminder_hours')}
value={settings.po_approval_reminder_hours}
onChange={handleChange('po_approval_reminder_hours')}
disabled={disabled}
@@ -172,7 +175,7 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
<Input
type="number"
label="Escalación Crítica (horas)"
label={t('procurement.critical_escalation_hours')}
value={settings.po_critical_escalation_hours}
onChange={handleChange('po_critical_escalation_hours')}
disabled={disabled}
@@ -183,6 +186,110 @@ const ProcurementSettingsCard: React.FC<ProcurementSettingsCardProps> = ({
/>
</div>
</div>
{/* Smart Procurement Calculation */}
<div className="border-t border-[var(--border-primary)] pt-6">
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-4 flex items-center">
<Brain className="w-4 h-4 mr-2" />
{t('procurement.smart_procurement')}
</h4>
<div className="space-y-3 pl-6">
<div className="flex items-start gap-2">
<input
type="checkbox"
id="use_reorder_rules"
checked={settings.use_reorder_rules}
onChange={handleChange('use_reorder_rules')}
disabled={disabled}
className="rounded border-[var(--border-primary)] mt-0.5"
/>
<div className="flex flex-col">
<label htmlFor="use_reorder_rules" className="text-sm font-medium text-[var(--text-secondary)]">
{t('procurement.use_reorder_rules')}
</label>
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
{t('procurement.use_reorder_rules_desc')}
</span>
</div>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="economic_rounding"
checked={settings.economic_rounding}
onChange={handleChange('economic_rounding')}
disabled={disabled}
className="rounded border-[var(--border-primary)] mt-0.5"
/>
<div className="flex flex-col">
<label htmlFor="economic_rounding" className="text-sm font-medium text-[var(--text-secondary)]">
{t('procurement.economic_rounding')}
</label>
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
{t('procurement.economic_rounding_desc')}
</span>
</div>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="respect_storage_limits"
checked={settings.respect_storage_limits}
onChange={handleChange('respect_storage_limits')}
disabled={disabled}
className="rounded border-[var(--border-primary)] mt-0.5"
/>
<div className="flex flex-col">
<label htmlFor="respect_storage_limits" className="text-sm font-medium text-[var(--text-secondary)]">
{t('procurement.respect_storage_limits')}
</label>
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
{t('procurement.respect_storage_limits_desc')}
</span>
</div>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="use_supplier_minimums"
checked={settings.use_supplier_minimums}
onChange={handleChange('use_supplier_minimums')}
disabled={disabled}
className="rounded border-[var(--border-primary)] mt-0.5"
/>
<div className="flex flex-col">
<label htmlFor="use_supplier_minimums" className="text-sm font-medium text-[var(--text-secondary)]">
{t('procurement.use_supplier_minimums')}
</label>
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
{t('procurement.use_supplier_minimums_desc')}
</span>
</div>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="optimize_price_tiers"
checked={settings.optimize_price_tiers}
onChange={handleChange('optimize_price_tiers')}
disabled={disabled}
className="rounded border-[var(--border-primary)] mt-0.5"
/>
<div className="flex flex-col">
<label htmlFor="optimize_price_tiers" className="text-sm font-medium text-[var(--text-secondary)]">
{t('procurement.optimize_price_tiers')}
</label>
<span className="text-xs text-[var(--text-tertiary)] mt-0.5">
{t('procurement.optimize_price_tiers_desc')}
</span>
</div>
</div>
</div>
</div>
</div>
</Card>
);

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react';
import { Button, Badge, Modal, Table, Select, StatsGrid, StatusCard, SearchAndFilter, type FilterConfig, Card } from '../../../../components/ui';
import { Button, Badge, Modal, Table, Select, StatsGrid, StatusCard, SearchAndFilter, type FilterConfig, Card, EmptyState } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { useCurrentTenant } from '../../../../stores/tenant.store';
@@ -116,7 +116,7 @@ const ModelsConfigPage: React.FC = () => {
hasModel: !!model,
model,
isTraining,
lastTrainingDate: model?.created_at,
lastTrainingDate: model?.created_at || undefined,
accuracy: model ?
(model.training_metrics?.mape !== undefined ? (100 - model.training_metrics.mape) :
(model as any).mape !== undefined ? (100 - (model as any).mape) :
@@ -209,13 +209,12 @@ const ModelsConfigPage: React.FC = () => {
}
return (
<div className="p-6 space-y-6">
<div className="space-y-6">
<PageHeader
title="Configuración de Modelos IA"
description="Gestiona el entrenamiento y configuración de modelos de predicción para cada ingrediente"
/>
{/* Statistics Cards */}
<StatsGrid
stats={[
@@ -232,39 +231,33 @@ const ModelsConfigPage: React.FC = () => {
variant: 'warning',
},
{
title: 'Modelos Huérfanos',
value: orphanedModels.length,
icon: AlertCircle,
variant: 'info',
title: 'Modelos Activos',
value: modelStatuses.filter(s => s.status === 'active').length,
icon: CheckCircle,
variant: 'success',
},
{
title: 'Precisión Promedio',
value: statsError ? 'N/A' : (statistics?.average_accuracy ? `${(100 - statistics.average_accuracy).toFixed(1)}%` : 'N/A'),
value: statsError ? 'N/A' : (statistics?.average_accuracy ? `${Number(statistics.average_accuracy).toFixed(1)}%` : 'N/A'),
icon: TrendingUp,
variant: 'success',
},
{
title: 'Total Modelos',
value: modelStatuses.length,
icon: Brain,
variant: 'info',
},
{
title: 'Modelos Huérfanos',
value: orphanedModels.length,
icon: AlertCircle,
variant: 'error',
},
]}
columns={4}
columns={3}
/>
{/* Orphaned Models Warning */}
{orphanedModels.length > 0 && (
<Card className="p-4 bg-orange-50 border-orange-200">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-orange-600 mt-0.5" />
<div>
<h4 className="font-medium text-orange-900 mb-1">
Modelos Huérfanos Detectados
</h4>
<p className="text-sm text-orange-700">
Se encontraron {orphanedModels.length} modelos entrenados para ingredientes que ya no existen en el inventario.
Estos modelos pueden ser eliminados para optimizar el espacio de almacenamiento.
</p>
</div>
</div>
</Card>
)}
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
@@ -289,18 +282,16 @@ const ModelsConfigPage: React.FC = () => {
/>
{/* Models Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredStatuses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 col-span-full">
<Brain className="w-12 h-12 text-[var(--color-secondary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron ingredientes
</h3>
<p className="text-[var(--text-secondary)] text-center">
No hay ingredientes que coincidan con los filtros aplicados.
</p>
</div>
) : (
{filteredStatuses.length === 0 ? (
<EmptyState
icon={Brain}
title="No se encontraron ingredientes"
description="No hay ingredientes que coincidan con los filtros aplicados."
className="col-span-full"
/>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{(
filteredStatuses.map((status) => {
// Get status configuration for the StatusCard
const statusConfig = {
@@ -335,7 +326,7 @@ const ModelsConfigPage: React.FC = () => {
id={status.ingredient.id}
statusIndicator={statusConfig}
title={status.ingredient.name}
subtitle={status.ingredient.category}
subtitle={status.ingredient.category || undefined}
primaryValue={status.accuracy ? status.accuracy.toFixed(1) : 'N/A'}
primaryValueLabel="Precisión"
secondaryInfo={status.lastTrainingDate ? {
@@ -371,7 +362,8 @@ const ModelsConfigPage: React.FC = () => {
);
})
)}
</div>
</div>
)}
{/* Training Modal */}
<Modal
@@ -463,4 +455,4 @@ const ModelsConfigPage: React.FC = () => {
);
};
export default ModelsConfigPage;
export default ModelsConfigPage;

View File

@@ -9,11 +9,7 @@ import { QualityTemplateManager } from '../../../../components/domain/production
* that are used during production processes.
*/
const QualityTemplatesPage: React.FC = () => {
return (
<div className="container mx-auto px-4 py-6">
<QualityTemplateManager />
</div>
);
return <QualityTemplateManager />;
};
export default QualityTemplatesPage;

View File

@@ -0,0 +1,580 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Leaf,
TrendingDown,
Euro,
Award,
Target,
Droplets,
TreeDeciduous,
Calendar,
Download,
FileText,
Info,
HelpCircle
} from 'lucide-react';
import { PageHeader } from '../../../../components/layout';
import { StatsGrid, Button, Card, Tooltip } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { useSustainabilityMetrics } from '../../../../api/hooks/sustainability';
import { useCurrentTenant } from '../../../../stores/tenant.store';
const SustainabilityPage: React.FC = () => {
const { t } = useTranslation(['sustainability', 'common']);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// Date range state (default to last 30 days)
const [dateRange, setDateRange] = useState<{ start?: string; end?: string }>({});
// Fetch sustainability metrics
const {
data: metrics,
isLoading,
error
} = useSustainabilityMetrics(tenantId, dateRange.start, dateRange.end, {
enabled: !!tenantId
});
// Build stats for StatsGrid
const sustainabilityStats = useMemo(() => {
if (!metrics) return [];
return [
{
title: t('sustainability:stats.total_waste_reduced', 'Total Waste Reduced'),
value: `${metrics.waste_metrics.total_waste_kg.toFixed(0)} kg`,
icon: TrendingDown,
variant: 'success' as const,
subtitle: t('sustainability:stats.from_baseline', 'From baseline'),
trend: metrics.waste_metrics.waste_percentage < 25 ? {
value: Math.abs(25 - metrics.waste_metrics.waste_percentage),
direction: 'down' as const,
label: t('sustainability:stats.vs_industry', 'vs industry avg')
} : undefined
},
{
title: t('sustainability:stats.waste_reduction_percentage', 'Waste Reduction'),
value: `${Math.abs(metrics.sdg_compliance.sdg_12_3.reduction_achieved).toFixed(1)}%`,
icon: Target,
variant: metrics.sdg_compliance.sdg_12_3.reduction_achieved >= 15 ? ('success' as const) : ('info' as const),
subtitle: t('sustainability:stats.progress_to_sdg', 'Progress to SDG 12.3'),
trend: {
value: metrics.sdg_compliance.sdg_12_3.progress_to_target,
direction: 'up' as const,
label: t('sustainability:stats.to_target', 'to 50% target')
}
},
{
title: t('sustainability:stats.co2_avoided', 'CO₂ Avoided'),
value: `${metrics.environmental_impact.co2_emissions.kg.toFixed(0)} kg`,
icon: Leaf,
variant: 'info' as const,
subtitle: `${metrics.environmental_impact.co2_emissions.trees_to_offset.toFixed(1)} ${t('sustainability:stats.trees', 'trees')}`
},
{
title: t('sustainability:stats.monthly_savings', 'Monthly Savings'),
value: `${metrics.financial_impact.potential_monthly_savings.toFixed(0)}`,
icon: Euro,
variant: 'success' as const,
subtitle: t('sustainability:stats.from_waste_reduction', 'From waste reduction')
},
{
title: t('sustainability:stats.sdg_progress', 'SDG 12.3 Progress'),
value: `${Math.round(metrics.sdg_compliance.sdg_12_3.progress_to_target)}%`,
icon: Award,
variant: metrics.sdg_compliance.sdg_12_3.status === 'sdg_compliant' ? ('success' as const) :
metrics.sdg_compliance.sdg_12_3.status === 'on_track' ? ('info' as const) : ('warning' as const),
subtitle: metrics.sdg_compliance.sdg_12_3.status_label
},
{
title: t('sustainability:stats.grant_programs', 'Grant Programs'),
value: Object.values(metrics.grant_readiness.grant_programs).filter(p => p.eligible).length.toString(),
icon: FileText,
variant: 'info' as const,
subtitle: t('sustainability:stats.eligible', 'Eligible programs')
}
];
}, [metrics, t]);
// Get SDG status color
const getSDGStatusColor = (status: string) => {
switch (status) {
case 'sdg_compliant':
return 'bg-green-500/10 text-green-600 border-green-500/20';
case 'on_track':
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
case 'progressing':
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20';
default:
return 'bg-gray-500/10 text-gray-600 border-gray-500/20';
}
};
if (isLoading) {
return (
<div className="space-y-6 p-4 sm:p-6">
<PageHeader
title={t('sustainability:page.title', 'Sostenibilidad')}
description={t('sustainability:page.description', 'Seguimiento de impacto ambiental y cumplimiento SDG 12.3')}
/>
<div className="flex items-center justify-center py-12">
<LoadingSpinner size="lg" />
</div>
</div>
);
}
if (error || !metrics) {
return (
<div className="space-y-6 p-4 sm:p-6">
<PageHeader
title={t('sustainability:page.title', 'Sostenibilidad')}
description={t('sustainability:page.description', 'Seguimiento de impacto ambiental y cumplimiento SDG 12.3')}
/>
<Card className="p-6">
<div className="text-center py-8">
<Leaf className="w-12 h-12 mx-auto mb-3 text-[var(--text-secondary)] opacity-50" />
<p className="text-sm text-[var(--text-secondary)]">
{t('sustainability:errors.load_failed', 'Unable to load sustainability metrics')}
</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6 p-4 sm:p-6">
{/* Page Header */}
<PageHeader
title={t('sustainability:page.title', 'Sostenibilidad')}
description={t('sustainability:page.description', 'Seguimiento de impacto ambiental y cumplimiento SDG 12.3')}
actions={[
{
id: "export-report",
label: t('sustainability:actions.export_report', 'Exportar Informe'),
icon: Download,
onClick: () => {
// TODO: Implement export
console.log('Export sustainability report');
},
variant: "outline",
size: "sm"
}
]}
/>
{/* Stats Grid */}
<StatsGrid
stats={sustainabilityStats}
columns={3}
gap="lg"
/>
{/* Main Content Sections */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Waste Analytics Section */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('sustainability:sections.waste_analytics', 'Análisis de Residuos')}
</h3>
<Tooltip content={t('sustainability:tooltips.waste_analytics', 'Información detallada sobre los residuos generados en la producción')}>
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
</Tooltip>
</div>
<p className="text-sm text-[var(--text-secondary)]">
{t('sustainability:sections.waste_subtitle', 'Desglose de residuos por tipo')}
</p>
</div>
<TrendingDown className="w-6 h-6 text-green-600" />
</div>
<div className="space-y-4">
{/* Waste breakdown */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">
{t('sustainability:waste.production', 'Residuos de Producción')}
</span>
<span className="text-sm font-medium text-[var(--text-primary)]">
{metrics.waste_metrics.production_waste_kg.toFixed(1)} kg
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">
{t('sustainability:waste.expired', 'Producto Expirado')}
</span>
<span className="text-sm font-medium text-[var(--text-primary)]">
{metrics.waste_metrics.expired_waste_kg.toFixed(1)} kg
</span>
</div>
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
<span className="text-sm font-semibold text-[var(--text-primary)]">
{t('sustainability:waste.total', 'Total')}
</span>
<span className="text-sm font-bold text-[var(--text-primary)]">
{metrics.waste_metrics.total_waste_kg.toFixed(1)} kg
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">
{t('sustainability:waste.percentage', 'Porcentaje de Residuos')}
</span>
<span className="text-sm font-medium text-[var(--text-primary)]">
{metrics.waste_metrics.waste_percentage.toFixed(2)}%
</span>
</div>
</div>
{/* AI Impact */}
<div className="mt-4 p-4 bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<Info className="w-4 h-4 text-blue-600" />
<span className="text-xs font-medium text-blue-700 dark:text-blue-400">
{t('sustainability:ai.impact_title', 'Impacto de IA')}
</span>
</div>
<p className="text-sm text-blue-600 dark:text-blue-300">
{t('sustainability:ai.waste_avoided', 'Residuos evitados')}: <strong>{metrics.avoided_waste.waste_avoided_kg.toFixed(1)} kg</strong>
</p>
<p className="text-xs text-blue-600/80 dark:text-blue-300/80 mt-1">
{t('sustainability:ai.batches', 'Lotes asistidos por IA')}: {metrics.avoided_waste.ai_assisted_batches}
</p>
</div>
</div>
</Card>
{/* Environmental Impact Section */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('sustainability:sections.environmental_impact', 'Impacto Ambiental')}
</h3>
<Tooltip content={t('sustainability:tooltips.environmental_impact', 'Métricas de huella ambiental y su equivalencia en términos cotidianos')}>
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
</Tooltip>
</div>
<p className="text-sm text-[var(--text-secondary)]">
{t('sustainability:sections.environmental_subtitle', 'Métricas de huella ambiental')}
</p>
</div>
<Leaf className="w-6 h-6 text-green-600" />
</div>
<div className="grid grid-cols-2 gap-4">
{/* CO2 */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Leaf className="w-4 h-4 text-green-600" />
<span className="text-xs font-medium text-[var(--text-secondary)]">CO</span>
</div>
<div className="text-xl font-bold text-[var(--text-primary)]">
{metrics.environmental_impact.co2_emissions.kg.toFixed(0)} kg
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{metrics.environmental_impact.co2_emissions.trees_to_offset.toFixed(1)} {t('sustainability:metrics.trees', 'árboles')}
</p>
</div>
{/* Water */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Droplets className="w-4 h-4 text-cyan-600" />
<span className="text-xs font-medium text-[var(--text-secondary)]">{t('sustainability:metrics.water', 'Agua')}</span>
</div>
<div className="text-xl font-bold text-[var(--text-primary)]">
{metrics.environmental_impact.water_footprint.cubic_meters.toFixed(1)} m³
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{metrics.environmental_impact.water_footprint.liters.toFixed(0)} {t('common:liters', 'litros')}
</p>
</div>
{/* Land Use */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<TreeDeciduous className="w-4 h-4 text-amber-600" />
<span className="text-xs font-medium text-[var(--text-secondary)]">{t('sustainability:metrics.land', 'Tierra')}</span>
</div>
<div className="text-xl font-bold text-[var(--text-primary)]">
{metrics.environmental_impact.land_use.square_meters.toFixed(0)} m²
</div>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{metrics.environmental_impact.land_use.hectares.toFixed(3)} {t('sustainability:metrics.hectares', 'hectáreas')}
</p>
</div>
{/* Human Equivalents */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Info className="w-4 h-4 text-blue-600" />
<span className="text-xs font-medium text-[var(--text-secondary)]">{t('sustainability:metrics.equivalents', 'Equivalentes')}</span>
</div>
<div className="text-xs space-y-1 text-[var(--text-secondary)]">
<div>🚗 {metrics.environmental_impact.human_equivalents.car_km_equivalent.toFixed(0)} km</div>
<div>📱 {metrics.environmental_impact.human_equivalents.smartphone_charges.toFixed(0)} cargas</div>
<div>🚿 {metrics.environmental_impact.human_equivalents.showers_equivalent.toFixed(0)} duchas</div>
</div>
</div>
</div>
</Card>
</div>
{/* SDG Compliance & Grant Readiness */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* SDG 12.3 Compliance */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('sustainability:sections.sdg_compliance', 'Cumplimiento SDG 12.3')}
</h3>
<Tooltip content={t('sustainability:tooltips.sdg_compliance', 'Progreso hacia el objetivo de desarrollo sostenible de la ONU para reducir residuos alimentarios')}>
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
</Tooltip>
</div>
<p className="text-sm text-[var(--text-secondary)]">
{t('sustainability:sections.sdg_subtitle', 'Progreso hacia objetivo ONU')}
</p>
</div>
<div className={`px-3 py-1 rounded-full border text-xs font-medium ${getSDGStatusColor(metrics.sdg_compliance.sdg_12_3.status)}`}>
{metrics.sdg_compliance.sdg_12_3.status_label}
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[var(--text-primary)]">
{t('sustainability:sdg.progress_label', 'Progreso al Objetivo')}
</span>
<span className="text-sm font-bold text-[var(--color-primary)]">
{Math.round(metrics.sdg_compliance.sdg_12_3.progress_to_target)}%
</span>
</div>
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-3 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-500"
style={{ width: `${Math.min(metrics.sdg_compliance.sdg_12_3.progress_to_target, 100)}%` }}
/>
</div>
<p className="text-xs text-[var(--text-secondary)] mt-2">
{t('sustainability:sdg.target_note', 'Objetivo: 50% reducción de residuos para 2030')}
</p>
</div>
{/* Metrics */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">
{t('sustainability:sdg.baseline', 'Línea Base')}
</span>
<span className="text-sm font-medium text-[var(--text-primary)]">
{metrics.sdg_compliance.sdg_12_3.baseline_waste_percentage.toFixed(2)}%
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">
{t('sustainability:sdg.current', 'Actual')}
</span>
<span className="text-sm font-medium text-[var(--text-primary)]">
{metrics.sdg_compliance.sdg_12_3.current_waste_percentage.toFixed(2)}%
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">
{t('sustainability:sdg.reduction', 'Reducción Lograda')}
</span>
<span className="text-sm font-bold text-green-600">
{Math.abs(metrics.sdg_compliance.sdg_12_3.reduction_achieved).toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
<span className="text-sm text-[var(--text-secondary)]">
{t('sustainability:sdg.certification_ready', 'Listo para Certificación')}
</span>
<span className={`text-sm font-medium ${metrics.sdg_compliance.certification_ready ? 'text-green-600' : 'text-amber-600'}`}>
{metrics.sdg_compliance.certification_ready ? t('common:yes', 'Sí') : t('common:no', 'No')}
</span>
</div>
</div>
</Card>
{/* Grant Readiness */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('sustainability:sections.grant_readiness', 'Subvenciones Disponibles')}
</h3>
<Tooltip content={t('sustainability:tooltips.grant_readiness', 'Programas de financiación disponibles para empresas españolas según la Ley 1/2025 de prevención de residuos')}>
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
</Tooltip>
</div>
<p className="text-sm text-[var(--text-secondary)]">
{t('sustainability:sections.grant_subtitle', 'Programas de financiación elegibles')}
</p>
</div>
<Award className="w-6 h-6 text-amber-600" />
</div>
{/* Overall Readiness */}
<div className="mb-4 p-4 bg-gradient-to-r from-amber-50 to-yellow-50 dark:from-amber-900/20 dark:to-yellow-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
{t('sustainability:grant.overall_readiness', 'Preparación General')}
</span>
<span className="text-lg font-bold text-amber-600 dark:text-amber-400">
{Math.round(metrics.grant_readiness.overall_readiness_percentage)}%
</span>
</div>
</div>
{/* Grant Programs List */}
<div className="space-y-3">
{Object.entries(metrics.grant_readiness.grant_programs).map(([key, program]) => (
<div
key={key}
className={`p-3 rounded-lg border ${
program.eligible
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: 'bg-gray-50 dark:bg-gray-800/20 border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${
program.eligible ? 'text-green-700 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'
}`}>
{key.replace(/_/g, ' ')}
</span>
{program.eligible && (
<span className="text-xs px-2 py-0.5 bg-green-500/20 text-green-700 dark:text-green-400 rounded-full">
{t('sustainability:grant.eligible', 'Elegible')}
</span>
)}
</div>
{program.funding_eur && program.funding_eur > 0 && (
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('sustainability:grant.funding', 'Financiación')}: {program.funding_eur.toLocaleString()}
</p>
)}
</div>
<div className={`text-xs px-2 py-1 rounded ${
program.confidence === 'high'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: program.confidence === 'medium'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
}`}>
{program.confidence}
</div>
</div>
</div>
))}
</div>
{/* Spain Compliance */}
<div className="mt-4 pt-4 border-t border-[var(--border-primary)]">
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">
{t('sustainability:grant.spain_compliance', 'Cumplimiento España')}
</p>
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1">
{metrics.grant_readiness.spain_compliance?.law_1_2025 ? '✅' : '❌'}
<span className="text-[var(--text-secondary)]">Ley 1/2025</span>
</div>
<div className="flex items-center gap-1">
{metrics.grant_readiness.spain_compliance?.circular_economy_strategy ? '✅' : '❌'}
<span className="text-[var(--text-secondary)]">Economía Circular 2030</span>
</div>
</div>
</div>
</Card>
</div>
{/* Financial Impact */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('sustainability:sections.financial_impact', 'Impacto Financiero')}
</h3>
<Tooltip content={t('sustainability:tooltips.financial_impact', 'Costes asociados a residuos y ahorros potenciales mediante la reducción de desperdicio')}>
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
</Tooltip>
</div>
<p className="text-sm text-[var(--text-secondary)]">
{t('sustainability:sections.financial_subtitle', 'Costes y ahorros de sostenibilidad')}
</p>
</div>
<Euro className="w-6 h-6 text-green-600" />
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">
{t('sustainability:financial.waste_cost', 'Coste de Residuos')}
</p>
<p className="text-2xl font-bold text-red-600">
{metrics.financial_impact.waste_cost_eur.toFixed(2)}
</p>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{metrics.financial_impact.cost_per_kg.toFixed(2)}/kg
</p>
</div>
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-xs font-medium text-green-700 dark:text-green-400 mb-2">
{t('sustainability:financial.monthly_savings', 'Ahorro Mensual')}
</p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
{metrics.financial_impact.potential_monthly_savings.toFixed(2)}
</p>
<p className="text-xs text-green-600/80 dark:text-green-400/80 mt-1">
{t('sustainability:financial.from_reduction', 'Por reducción')}
</p>
</div>
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">
{t('sustainability:financial.annual_projection', 'Proyección Anual')}
</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">
{metrics.financial_impact.annual_projection.toFixed(2)}
</p>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('sustainability:financial.estimated', 'Estimado')}
</p>
</div>
<div className="p-4 bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-xs font-medium text-blue-700 dark:text-blue-400 mb-2">
{t('sustainability:financial.roi', 'ROI de IA')}
</p>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{(metrics.avoided_waste.waste_avoided_kg * metrics.financial_impact.cost_per_kg).toFixed(2)}
</p>
<p className="text-xs text-blue-600/80 dark:text-blue-400/80 mt-1">
{t('sustainability:financial.ai_savings', 'Ahorrado por IA')}
</p>
</div>
</div>
</Card>
</div>
);
};
export default SustainabilityPage;

View File

@@ -1,6 +1,7 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight, Minus, Edit, Trash2, Archive, TrendingUp, History } from 'lucide-react';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, Card } from '../../../../components/ui';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, Card, EmptyState } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
@@ -14,12 +15,14 @@ import {
// Import AddStockModal separately since we need it for adding batches
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useUpdateStock, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
import { subscriptionService } from '../../../../api/services/subscription';
import { useQueryClient } from '@tanstack/react-query';
const InventoryPage: React.FC = () => {
const { t } = useTranslation(['inventory', 'common']);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
@@ -34,6 +37,7 @@ const InventoryPage: React.FC = () => {
const [showAddBatch, setShowAddBatch] = useState(false);
const tenantId = useTenantId();
const queryClient = useQueryClient();
// Debug tenant ID
console.log('🔍 [InventoryPage] Tenant ID from hook:', tenantId);
@@ -47,12 +51,14 @@ const InventoryPage: React.FC = () => {
const addStockMutation = useAddStock();
const consumeStockMutation = useConsumeStock();
const updateIngredientMutation = useUpdateIngredient();
const updateStockMutation = useUpdateStock();
// API Data
const {
data: ingredientsData,
isLoading: ingredientsLoading,
error: ingredientsError
error: ingredientsError,
isRefetching: isRefetchingIngredients
} = useIngredients(tenantId, { search: searchTerm || undefined });
@@ -85,7 +91,8 @@ const InventoryPage: React.FC = () => {
const {
data: stockLotsData,
isLoading: stockLotsLoading,
error: stockLotsError
error: stockLotsError,
isRefetching: isRefetchingBatches
} = useStockByIngredient(
tenantId,
selectedItem?.id || '',
@@ -283,12 +290,30 @@ const InventoryPage: React.FC = () => {
});
}, [ingredients, searchTerm, statusFilter, categoryFilter]);
// Helper function to get category display name
// Helper function to get translated category display name
const getCategoryDisplayName = (category?: string): string => {
if (!category) return 'Sin categoría';
if (!category) return t('inventory:categories.all', 'Sin categoría');
// Try ingredient category translation first
const ingredientTranslation = t(`inventory:enums.ingredient_category.${category}`, { defaultValue: '' });
if (ingredientTranslation) return ingredientTranslation;
// Try product category translation
const productTranslation = t(`inventory:enums.product_category.${category}`, { defaultValue: '' });
if (productTranslation) return productTranslation;
// Fallback to raw category if no translation found
return category;
};
// Helper function to get translated unit display name
const getUnitDisplayName = (unit?: string): string => {
if (!unit) return '';
// Translate unit of measure
return t(`inventory:enums.unit_of_measure.${unit}`, { defaultValue: unit });
};
// Focused action handlers
const handleShowInfo = (ingredient: IngredientResponse) => {
setSelectedItem(ingredient);
@@ -325,7 +350,7 @@ const InventoryPage: React.FC = () => {
try {
// Check subscription limits before creating
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'inventory_items', 1);
const usageCheck = await subscriptionService.checkQuotaLimit(tenantId, 'inventory_items', 1);
if (!usageCheck.allowed) {
throw new Error(
@@ -397,6 +422,22 @@ const InventoryPage: React.FC = () => {
});
};
// Refetch callbacks for wait-for-refetch pattern
const handleIngredientSaveComplete = async () => {
if (!tenantId) return;
// Invalidate ingredients query to trigger refetch
await queryClient.invalidateQueries(['ingredients', tenantId]);
};
const handleBatchSaveComplete = async () => {
if (!tenantId || !selectedItem?.id) return;
// Invalidate both ingredients (for updated stock totals) and stock lots queries
await Promise.all([
queryClient.invalidateQueries(['ingredients', tenantId]),
queryClient.invalidateQueries(['stock', 'by-ingredient', tenantId, selectedItem.id])
]);
};
const inventoryStats = useMemo(() => {
@@ -516,7 +557,7 @@ const InventoryPage: React.FC = () => {
<div className="space-y-6">
<PageHeader
title="Gestión de Inventario"
description="Controla el stock de ingredientes y materias primas"
description="Gestiona stock, costos, lotes y alertas de ingredientes"
actions={[
{
id: "add-new-item",
@@ -598,7 +639,7 @@ const InventoryPage: React.FC = () => {
title={ingredient.name}
subtitle={getCategoryDisplayName(ingredient.category)}
primaryValue={currentStock}
primaryValueLabel={ingredient.unit_of_measure}
primaryValueLabel={getUnitDisplayName(ingredient.unit_of_measure)}
secondaryInfo={{
label: 'Valor',
value: formatters.currency(totalValue)
@@ -610,7 +651,7 @@ const InventoryPage: React.FC = () => {
} : undefined}
onClick={() => handleShowInfo(ingredient)}
actions={[
// Primary action - View item details
// Primary action - View item details (left side)
{
label: 'Ver Detalles',
icon: Eye,
@@ -618,27 +659,27 @@ const InventoryPage: React.FC = () => {
priority: 'primary',
onClick: () => handleShowInfo(ingredient)
},
// Stock history action - Icon button
// Delete action - Icon button (right side)
{
label: 'Eliminar',
icon: Trash2,
priority: 'secondary',
onClick: () => handleDelete(ingredient)
},
// Stock history action - Icon button (right side)
{
label: 'Historial',
icon: History,
priority: 'secondary',
onClick: () => handleShowStockHistory(ingredient)
},
// Batch management action
// View stock batches - Highlighted icon button (right side)
{
label: 'Ver Lotes',
icon: Package,
priority: 'secondary',
highlighted: true,
onClick: () => handleShowBatches(ingredient)
},
// Destructive action
{
label: 'Eliminar',
icon: Trash2,
priority: 'secondary',
destructive: true,
onClick: () => handleDelete(ingredient)
}
]}
/>
@@ -648,24 +689,14 @@ const InventoryPage: React.FC = () => {
{/* Empty State */}
{filteredItems.length === 0 && (
<div className="text-center py-12">
<Package 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 artículos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o agregar un nuevo artículo al inventario
</p>
<Button
onClick={handleNewItem}
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>Nuevo Artículo</span>
</Button>
</div>
<EmptyState
icon={Package}
title="No se encontraron artículos"
description="Intenta ajustar la búsqueda o agregar un nuevo artículo al inventario"
actionLabel="Nuevo Artículo"
actionIcon={Plus}
onAction={handleNewItem}
/>
)}
{/* Focused Action Modals */}
@@ -691,12 +722,23 @@ const InventoryPage: React.FC = () => {
throw new Error('Missing tenant ID or selected item');
}
// Validate we have actual data to update
if (!updatedData || Object.keys(updatedData).length === 0) {
console.error('InventoryPage: No data provided for ingredient update');
throw new Error('No data provided for update');
}
console.log('InventoryPage: Updating ingredient with data:', updatedData);
return updateIngredientMutation.mutateAsync({
tenantId,
ingredientId: selectedItem.id,
updateData: updatedData
});
}}
waitForRefetch={true}
isRefetching={isRefetchingIngredients}
onSaveComplete={handleIngredientSaveComplete}
/>
<StockHistoryModal
@@ -719,17 +761,36 @@ const InventoryPage: React.FC = () => {
ingredient={selectedItem}
batches={stockLotsData || []}
loading={stockLotsLoading}
tenantId={tenantId}
onAddBatch={() => {
setShowAddBatch(true);
}}
onEditBatch={async (batchId, updateData) => {
// TODO: Implement edit batch functionality
console.log('Edit batch:', batchId, updateData);
if (!tenantId) {
throw new Error('No tenant ID available');
}
// Validate we have actual data to update
if (!updateData || Object.keys(updateData).length === 0) {
console.error('InventoryPage: No data provided for batch update');
throw new Error('No data provided for update');
}
console.log('InventoryPage: Updating batch with data:', updateData);
return updateStockMutation.mutateAsync({
tenantId,
stockId: batchId,
updateData
});
}}
onMarkAsWaste={async (batchId) => {
// TODO: Implement mark as waste functionality
console.log('Mark as waste:', batchId);
}}
waitForRefetch={true}
isRefetching={isRefetchingBatches}
onSaveComplete={handleBatchSaveComplete}
/>
<DeleteIngredientModal
@@ -751,6 +812,9 @@ const InventoryPage: React.FC = () => {
}}
ingredient={selectedItem}
onAddStock={handleAddStockSubmit}
waitForRefetch={true}
isRefetching={isRefetchingBatches || isRefetchingIngredients}
onSaveComplete={handleBatchSaveComplete}
/>
</>
)}
@@ -758,4 +822,4 @@ const InventoryPage: React.FC = () => {
);
};
export default InventoryPage;
export default InventoryPage;

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, AlertTriangle, Settings, CheckCircle, Eye, Wrench, Thermometer, Activity, Search, Filter, Bell, History, Calendar, Edit, Trash2 } from 'lucide-react';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
import { Badge } from '../../../../components/ui/Badge';
import { LoadingSpinner } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
@@ -164,22 +164,39 @@ const MaquinariaPage: React.FC = () => {
{
title: t('labels.total_equipment'),
value: equipmentStats.total,
variant: 'default' as const,
icon: Settings,
variant: 'default' as const
},
{
title: t('labels.operational'),
value: equipmentStats.operational,
icon: CheckCircle,
variant: 'success' as const,
subtitle: `${((equipmentStats.operational / equipmentStats.total) * 100).toFixed(1)}%`
icon: CheckCircle,
},
{
title: t('labels.warning'),
value: equipmentStats.warning,
variant: 'warning' as const,
icon: AlertTriangle,
},
{
title: t('labels.maintenance_required'),
value: equipmentStats.maintenance,
variant: 'info' as const,
icon: Wrench,
},
{
title: t('labels.down'),
value: equipmentStats.down,
variant: 'error' as const,
icon: AlertTriangle,
},
{
title: t('labels.active_alerts'),
value: equipmentStats.totalAlerts,
variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const,
icon: Bell,
variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const
}
},
];
const handleShowMaintenanceDetails = (equipment: Equipment) => {
@@ -345,24 +362,14 @@ const MaquinariaPage: React.FC = () => {
{/* Empty State */}
{filteredEquipment.length === 0 && (
<div className="text-center py-12">
<Settings 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">
{t('common:forms.no_results')}
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{t('common:forms.empty_state')}
</p>
<Button
onClick={handleCreateEquipment}
variant="primary"
size="md"
className="font-medium px-4 sm:px-6 py-2 sm:py-3 shadow-sm hover:shadow-md transition-all duration-200"
>
<Plus className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0" />
<span className="text-sm sm:text-base">{t('actions.add_equipment')}</span>
</Button>
</div>
<EmptyState
icon={Settings}
title={t('common:forms.no_results')}
description={t('common:forms.empty_state')}
actionLabel={t('actions.add_equipment')}
actionIcon={Plus}
onAction={handleCreateEquipment}
/>
)}
{/* Maintenance Details Modal */}
@@ -558,4 +565,4 @@ const MaquinariaPage: React.FC = () => {
);
};
export default MaquinariaPage;
export default MaquinariaPage;

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Plus, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Loader, Euro } from 'lucide-react';
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, Tabs, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, Tabs, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import {
@@ -8,6 +8,8 @@ import {
OrderResponse,
CustomerResponse,
OrderCreate,
CustomerCreate,
CustomerUpdate,
PaymentStatus,
DeliveryMethod,
PaymentMethod,
@@ -19,7 +21,7 @@ import {
CustomerType,
CustomerSegment
} from '../../../../api/types/orders';
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateCustomer } from '../../../../api/hooks/orders';
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateCustomer, useUpdateCustomer } from '../../../../api/hooks/orders';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { OrderFormModal } from '../../../../components/domain/orders';
@@ -75,6 +77,7 @@ const OrdersPage: React.FC = () => {
// Mutations
const createOrderMutation = useCreateOrder();
const createCustomerMutation = useCreateCustomer();
const updateCustomerMutation = useUpdateCustomer();
const orders = ordersData || [];
const customers = customersData || [];
@@ -206,12 +209,12 @@ const OrdersPage: React.FC = () => {
variant: 'success' as const,
icon: Users,
},
{
title: 'Tasa de Repetición',
value: `${(orderStats.repeat_customers_rate * 100).toFixed(1)}%`,
variant: 'info' as const,
icon: Users,
},
{
title: 'Tasa de Repetición',
value: `${Number(orderStats.repeat_customers_rate).toFixed(1)}%`,
variant: 'info' as const,
icon: Users,
},
{
title: 'Clientes Activos',
value: customers.filter(c => c.is_active).length,
@@ -398,17 +401,6 @@ const OrdersPage: React.FC = () => {
setModalMode('view');
setShowForm(true);
}
},
{
label: 'Editar',
icon: Edit,
priority: 'secondary',
onClick: () => {
setSelectedOrder(order);
setIsCreating(false);
setModalMode('edit');
setShowForm(true);
}
}
]}
/>
@@ -455,17 +447,6 @@ const OrdersPage: React.FC = () => {
setModalMode('view');
setShowForm(true);
}
},
{
label: 'Editar',
icon: Edit,
priority: 'secondary',
onClick: () => {
setSelectedCustomer(customer);
setIsCreating(false);
setModalMode('edit');
setShowForm(true);
}
}
]}
/>
@@ -476,31 +457,24 @@ const OrdersPage: React.FC = () => {
{/* Empty State */}
{activeTab === 'orders' && filteredOrders.length === 0 && (
<div className="text-center py-12">
<Package 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 pedidos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o crear un nuevo pedido
</p>
<Button onClick={() => setShowNewOrderForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Nuevo Pedido
</Button>
</div>
<EmptyState
icon={Package}
title="No se encontraron pedidos"
description="Intenta ajustar la búsqueda o crear un nuevo pedido"
actionLabel="Nuevo Pedido"
actionIcon={Plus}
onAction={() => setShowNewOrderForm(true)}
/>
)}
{activeTab === 'customers' && filteredCustomers.length === 0 && (
<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 clientes
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o crear un nuevo cliente
</p>
<Button onClick={() => {
<EmptyState
icon={Users}
title="No se encontraron clientes"
description="Intenta ajustar la búsqueda o crear un nuevo cliente"
actionLabel="Nuevo Cliente"
actionIcon={Plus}
onAction={() => {
setSelectedCustomer({
name: '',
business_name: '',
@@ -518,11 +492,8 @@ const OrdersPage: React.FC = () => {
setIsCreating(true);
setModalMode('edit');
setShowForm(true);
}}>
<Plus className="w-4 h-4 mr-2" />
Nuevo Cliente
</Button>
</div>
}}
/>
)}
{/* Order Details Modal */}
@@ -663,7 +634,11 @@ const OrdersPage: React.FC = () => {
sections={sections}
showDefaultActions={true}
onSave={async () => {
// TODO: Implement order update functionality
// Note: The backend only has updateOrderStatus, not a general update endpoint
// For now, orders can be updated via status changes using useUpdateOrderStatus
console.log('Saving order:', selectedOrder);
console.warn('Order update not yet implemented - only status updates are supported via useUpdateOrderStatus');
}}
onFieldChange={(sectionIndex, fieldIndex, value) => {
const newOrder = { ...selectedOrder };
@@ -739,6 +714,13 @@ const OrdersPage: React.FC = () => {
value: selectedCustomer.city || '',
type: 'text',
editable: true
},
{
label: 'País',
value: selectedCustomer.country || 'España',
type: 'text',
editable: isCreating,
highlight: false
}
]
},
@@ -829,7 +811,69 @@ const OrdersPage: React.FC = () => {
sections={sections}
showDefaultActions={true}
onSave={async () => {
console.log('Saving customer:', selectedCustomer);
if (!selectedCustomer || !tenantId) {
console.error('Missing required data for customer save');
return;
}
try {
if (isCreating) {
// Create new customer
const customerData: CustomerCreate = {
tenant_id: tenantId,
customer_code: selectedCustomer.customer_code || `CUST-${Date.now()}`,
name: selectedCustomer.name,
business_name: selectedCustomer.business_name,
customer_type: selectedCustomer.customer_type,
email: selectedCustomer.email,
phone: selectedCustomer.phone,
city: selectedCustomer.city,
country: selectedCustomer.country || 'España',
is_active: selectedCustomer.is_active,
preferred_delivery_method: selectedCustomer.preferred_delivery_method,
payment_terms: selectedCustomer.payment_terms,
discount_percentage: selectedCustomer.discount_percentage,
customer_segment: selectedCustomer.customer_segment,
priority_level: selectedCustomer.priority_level,
special_instructions: selectedCustomer.special_instructions
};
await createCustomerMutation.mutateAsync(customerData);
console.log('Customer created successfully');
} else {
// Update existing customer
const updateData: CustomerUpdate = {
name: selectedCustomer.name,
business_name: selectedCustomer.business_name,
customer_type: selectedCustomer.customer_type,
email: selectedCustomer.email,
phone: selectedCustomer.phone,
city: selectedCustomer.city,
preferred_delivery_method: selectedCustomer.preferred_delivery_method,
payment_terms: selectedCustomer.payment_terms,
discount_percentage: selectedCustomer.discount_percentage,
customer_segment: selectedCustomer.customer_segment,
is_active: selectedCustomer.is_active,
special_instructions: selectedCustomer.special_instructions
};
await updateCustomerMutation.mutateAsync({
tenantId,
customerId: selectedCustomer.id!,
data: updateData
});
console.log('Customer updated successfully');
}
// Close modal and reset state
setShowForm(false);
setSelectedCustomer(null);
setIsCreating(false);
setModalMode('view');
} catch (error) {
console.error('Error saving customer:', error);
throw error; // Let the modal show the error
}
}}
onFieldChange={(sectionIndex, fieldIndex, value) => {
const newCustomer = { ...selectedCustomer };
@@ -843,6 +887,7 @@ const OrdersPage: React.FC = () => {
'Email': 'email',
'Teléfono': 'phone',
'Ciudad': 'city',
'País': 'country',
'Código de Cliente': 'customer_code',
'Método de Entrega Preferido': 'preferred_delivery_method',
'Términos de Pago': 'payment_terms',
@@ -880,4 +925,4 @@ const OrdersPage: React.FC = () => {
);
};
export default OrdersPage;
export default OrdersPage;

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Plus, ShoppingCart, Euro, Calendar, CheckCircle, AlertCircle, Package, Eye, X, Send, Building2, Play, FileText, Star, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig } from '../../../../components/ui';
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig, EmptyState } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal';
@@ -799,19 +799,14 @@ const ProcurementPage: React.FC = () => {
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
) : filteredPOs.length === 0 ? (
<Card className="text-center py-12">
<ShoppingCart className="h-16 w-16 mx-auto mb-4 text-gray-400" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No hay órdenes de compra
</h3>
<p className="text-gray-500 mb-4">
Comienza creando una nueva orden de compra
</p>
<Button onClick={() => setShowCreatePOModal(true)}>
<Plus className="h-4 w-4 mr-2" />
Nueva Orden
</Button>
</Card>
<EmptyState
icon={ShoppingCart}
title="No hay órdenes de compra"
description="Comienza creando una nueva orden de compra"
actionLabel="Nueva Orden"
actionIcon={Plus}
onAction={() => setShowCreatePOModal(true)}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredPOs.map((po) => {

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle, Play } from 'lucide-react';
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
import { statusColors } from '../../../../styles/colors';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { LoadingSpinner } from '../../../../components/ui';
@@ -471,22 +471,18 @@ const ProductionPage: React.FC = () => {
{/* Empty State */}
{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 lotes de producción
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{batches.length === 0
<EmptyState
icon={ChefHat}
title="No se encontraron lotes de producción"
description={
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={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Nueva Orden de Producción
</Button>
</div>
}
actionLabel="Nueva Orden de Producción"
actionIcon={Plus}
onAction={() => setShowCreateModal(true)}
/>
)}
</>

View File

@@ -1,16 +1,143 @@
import React, { useState, useMemo } from 'react';
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle } from 'lucide-react';
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react';
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/ui';
import { QualityPromptDialog } from '../../../../components/ui/QualityPromptDialog';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe } from '../../../../api/hooks/recipes';
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe, useArchiveRecipe } from '../../../../api/hooks/recipes';
import { recipesService } from '../../../../api/services/recipes';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import type { RecipeResponse, RecipeCreate, MeasurementUnit } from '../../../../api/types/recipes';
import type { RecipeResponse, RecipeCreate } from '../../../../api/types/recipes';
import { MeasurementUnit } from '../../../../api/types/recipes';
import { useQualityTemplatesForRecipe } from '../../../../api/hooks/qualityTemplates';
import { useIngredients } from '../../../../api/hooks/inventory';
import { ProcessStage, type RecipeQualityConfiguration } from '../../../../api/types/qualityTemplates';
import { CreateRecipeModal } from '../../../../components/domain/recipes';
import { CreateRecipeModal, DeleteRecipeModal } from '../../../../components/domain/recipes';
import { QualityCheckConfigurationModal } from '../../../../components/domain/recipes/QualityCheckConfigurationModal';
import { useQueryClient } from '@tanstack/react-query';
import type { RecipeIngredientResponse } from '../../../../api/types/recipes';
// Ingredients Edit Component for EditViewModal
const IngredientsEditComponent: React.FC<{
value: RecipeIngredientResponse[];
onChange: (value: RecipeIngredientResponse[]) => void;
availableIngredients: Array<{value: string; label: string}>;
unitOptions: Array<{value: MeasurementUnit; label: string}>;
}> = ({ value, onChange, availableIngredients, unitOptions }) => {
const ingredientsArray = Array.isArray(value) ? value : [];
const addIngredient = () => {
const newIngredient: Partial<RecipeIngredientResponse> = {
id: `temp-${Date.now()}`, // Temporary ID for new ingredients
ingredient_id: '',
quantity: 1,
unit: MeasurementUnit.GRAMS,
ingredient_order: ingredientsArray.length + 1,
is_optional: false
};
onChange([...ingredientsArray, newIngredient as RecipeIngredientResponse]);
};
const removeIngredient = (index: number) => {
onChange(ingredientsArray.filter((_, i) => i !== index));
};
const updateIngredient = (index: number, field: keyof RecipeIngredientResponse, newValue: any) => {
const updated = ingredientsArray.map((ingredient, i) =>
i === index ? { ...ingredient, [field]: newValue } : ingredient
);
onChange(updated);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-[var(--text-primary)]">Lista de Ingredientes</h4>
<button
type="button"
onClick={addIngredient}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors"
>
<Plus className="w-4 h-4" />
Agregar
</button>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{ingredientsArray.map((ingredient, index) => (
<div key={ingredient.id || index} className="p-3 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--text-primary)]">Ingrediente #{index + 1}</span>
<button
type="button"
onClick={() => removeIngredient(index)}
className="p-1 text-red-500 hover:text-red-700 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingrediente</label>
<select
value={ingredient.ingredient_id}
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
>
<option value="">Seleccionar...</option>
{availableIngredients.map(ing => (
<option key={ing.value} value={ing.value}>{ing.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Cantidad</label>
<input
type="number"
value={ingredient.quantity}
onChange={(e) => updateIngredient(index, 'quantity', parseFloat(e.target.value) || 0)}
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
min="0"
step="0.1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad</label>
<select
value={ingredient.unit}
onChange={(e) => updateIngredient(index, 'unit', e.target.value as MeasurementUnit)}
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
>
{unitOptions.map(unit => (
<option key={unit.value} value={unit.value}>{unit.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1 flex items-center gap-1">
<input
type="checkbox"
checked={ingredient.is_optional}
onChange={(e) => updateIngredient(index, 'is_optional', e.target.checked)}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
Opcional
</label>
</div>
</div>
</div>
))}
</div>
</div>
);
};
const RecipesPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
@@ -22,21 +149,29 @@ const RecipesPage: React.FC = () => {
const [selectedRecipe, setSelectedRecipe] = useState<RecipeResponse | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showQualityConfigModal, setShowQualityConfigModal] = useState(false);
const [showQualityPrompt, setShowQualityPrompt] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [recipeToDelete, setRecipeToDelete] = useState<RecipeResponse | null>(null);
const [newlyCreatedRecipe, setNewlyCreatedRecipe] = useState<RecipeResponse | null>(null);
const [editedRecipe, setEditedRecipe] = useState<Partial<RecipeResponse>>({});
const [editedIngredients, setEditedIngredients] = useState<RecipeIngredientResponse[]>([]);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const queryClient = useQueryClient();
// Mutations
const createRecipeMutation = useCreateRecipe(tenantId);
const updateRecipeMutation = useUpdateRecipe(tenantId);
const deleteRecipeMutation = useDeleteRecipe(tenantId);
const archiveRecipeMutation = useArchiveRecipe(tenantId);
// API Data
const {
data: recipes = [],
isLoading: recipesLoading,
error: recipesError
error: recipesError,
isRefetching: isRefetchingRecipes
} = useRecipes(tenantId, { search_term: searchTerm || undefined });
const {
@@ -44,6 +179,42 @@ const RecipesPage: React.FC = () => {
isLoading: statisticsLoading
} = useRecipeStatistics(tenantId);
// Fetch inventory items for ingredient name lookup
const {
data: inventoryItems = [],
isLoading: inventoryLoading
} = useIngredients(tenantId, {});
// Create ingredient lookup map (UUID -> name)
const ingredientLookup = useMemo(() => {
const map: Record<string, string> = {};
inventoryItems.forEach(item => {
map[item.id] = item.name;
});
return map;
}, [inventoryItems]);
// Available ingredients for editing
const availableIngredients = useMemo(() =>
(inventoryItems || [])
.filter(item => item.product_type !== 'finished_product')
.map(ingredient => ({
value: ingredient.id,
label: `${ingredient.name} (${ingredient.category || 'Sin categoría'})`
})),
[inventoryItems]
);
// Unit options for ingredients
const unitOptions = useMemo(() => [
{ value: MeasurementUnit.GRAMS, label: 'g' },
{ value: MeasurementUnit.KILOGRAMS, label: 'kg' },
{ value: MeasurementUnit.MILLILITERS, label: 'ml' },
{ value: MeasurementUnit.LITERS, label: 'L' },
{ value: MeasurementUnit.UNITS, label: 'unidades' },
{ value: MeasurementUnit.TABLESPOONS, label: 'cucharadas' },
{ value: MeasurementUnit.TEASPOONS, label: 'cucharaditas' },
], []);
const getRecipeStatusConfig = (recipe: RecipeResponse) => {
const category = recipe.category || 'other';
@@ -106,6 +277,33 @@ const RecipesPage: React.FC = () => {
return `Configurado para ${configuredStages.length} etapas`;
};
const getQualityIndicator = (recipe: RecipeResponse) => {
if (!recipe.quality_check_configuration || !recipe.quality_check_configuration.stages) {
return '❌ Sin configurar';
}
const stages = recipe.quality_check_configuration.stages;
const configuredStages = Object.keys(stages).filter(
stage => stages[stage]?.template_ids?.length > 0
);
const totalTemplates = Object.values(stages).reduce(
(sum, stage) => sum + (stage.template_ids?.length || 0),
0
);
if (configuredStages.length === 0) {
return '❌ Sin configurar';
}
const totalStages = Object.keys(ProcessStage).length;
if (configuredStages.length < totalStages / 2) {
return `⚠️ Parcial (${configuredStages.length}/${totalStages} etapas)`;
}
return `✅ Configurado (${totalTemplates} controles)`;
};
const filteredRecipes = useMemo(() => {
let filtered = recipes;
@@ -197,11 +395,30 @@ const RecipesPage: React.FC = () => {
},
];
// Handle opening a recipe (fetch full details with ingredients)
const handleOpenRecipe = async (recipeId: string) => {
try {
// Fetch full recipe details including ingredients
const fullRecipe = await recipesService.getRecipe(tenantId, recipeId);
setSelectedRecipe(fullRecipe);
setModalMode('view');
setShowForm(true);
} catch (error) {
console.error('Error fetching recipe details:', error);
}
};
// Handle creating a new recipe
const handleCreateRecipe = async (recipeData: RecipeCreate) => {
try {
await createRecipeMutation.mutateAsync(recipeData);
const newRecipe = await createRecipeMutation.mutateAsync(recipeData);
setShowCreateModal(false);
// Fetch full recipe details and show quality prompt
const fullRecipe = await recipesService.getRecipe(tenantId, newRecipe.id);
setNewlyCreatedRecipe(fullRecipe);
setShowQualityPrompt(true);
console.log('Recipe created successfully');
} catch (error) {
console.error('Error creating recipe:', error);
@@ -209,20 +426,71 @@ const RecipesPage: React.FC = () => {
}
};
// Handle quality prompt - configure now
const handleConfigureQualityNow = async () => {
setShowQualityPrompt(false);
if (newlyCreatedRecipe) {
setSelectedRecipe(newlyCreatedRecipe);
setModalMode('edit');
setShowQualityConfigModal(true);
setShowForm(true);
}
};
// Handle quality prompt - configure later
const handleConfigureQualityLater = () => {
setShowQualityPrompt(false);
setNewlyCreatedRecipe(null);
};
// Handle field changes in edit mode
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
if (!selectedRecipe) return;
const fieldMap: Record<string, string> = {
// Información Básica
'Nombre': 'name',
'Código de receta': 'recipe_code',
'Versión': 'version',
'Descripción': 'description',
'Categoría': 'category',
'Producto terminado': 'finished_product_id',
'Tipo de cocina': 'cuisine_type',
'Dificultad': 'difficulty_level',
'Estado': 'status',
'Rendimiento': 'yield_quantity',
'Unidad de rendimiento': 'yield_unit',
'Porciones': 'serves_count',
// Tiempos
'Tiempo de preparación': 'prep_time_minutes',
'Tiempo de cocción': 'cook_time_minutes',
'Tiempo de reposo': 'rest_time_minutes',
// Configuración Especial
'Receta estacional': 'is_seasonal',
'Mes de inicio': 'season_start_month',
'Mes de fin': 'season_end_month',
'Receta estrella': 'is_signature_item',
// Configuración de Producción
'Multiplicador de lote': 'batch_size_multiplier',
'Tamaño mínimo de lote': 'minimum_batch_size',
'Tamaño máximo de lote': 'maximum_batch_size',
'Temperatura óptima': 'optimal_production_temperature',
'Humedad óptima': 'optimal_humidity',
// Análisis Financiero
'Costo estimado por unidad': 'estimated_cost_per_unit',
'Precio de venta sugerido': 'suggested_selling_price',
'Margen objetivo': 'target_margin_percentage'
'Margen objetivo': 'target_margin_percentage',
// Instrucciones y Calidad
'Notas de preparación': 'preparation_notes',
'Instrucciones de almacenamiento': 'storage_instructions',
'Estándares de calidad': 'quality_standards',
'Instrucciones de preparación': 'instructions',
'Puntos de control de calidad': 'quality_check_points',
'Problemas comunes y soluciones': 'common_issues',
// Información Nutricional
'Información de alérgenos': 'allergen_info',
'Etiquetas dietéticas': 'dietary_tags',
'Información nutricional': 'nutritional_info'
};
const sections = getModalSections();
@@ -237,12 +505,19 @@ const RecipesPage: React.FC = () => {
}
};
// Refetch callback for wait-for-refetch pattern
const handleRecipeSaveComplete = async () => {
if (!tenantId) return;
// Invalidate recipes query to trigger refetch
await queryClient.invalidateQueries(['recipes', tenantId]);
};
// Handle saving edited recipe
const handleSaveRecipe = async () => {
if (!selectedRecipe || !Object.keys(editedRecipe).length) return;
if (!selectedRecipe || (!Object.keys(editedRecipe).length && editedIngredients.length === 0)) return;
try {
const updateData = {
const updateData: any = {
...editedRecipe,
// Convert time fields from formatted strings back to numbers if needed
prep_time_minutes: typeof editedRecipe.prep_time_minutes === 'string'
@@ -259,13 +534,33 @@ const RecipesPage: React.FC = () => {
: editedRecipe.difficulty_level,
};
// Include ingredient updates if they were edited
if (editedIngredients.length > 0) {
updateData.ingredients = editedIngredients.map((ing, index) => ({
ingredient_id: ing.ingredient_id,
quantity: ing.quantity,
unit: ing.unit,
alternative_quantity: ing.alternative_quantity || null,
alternative_unit: ing.alternative_unit || null,
preparation_method: ing.preparation_method || null,
ingredient_notes: ing.ingredient_notes || null,
is_optional: ing.is_optional || false,
ingredient_order: index + 1, // Maintain order based on array position
ingredient_group: ing.ingredient_group || null,
substitution_options: ing.substitution_options || null,
substitution_ratio: ing.substitution_ratio || null,
}));
}
await updateRecipeMutation.mutateAsync({
id: selectedRecipe.id,
data: updateData
});
setModalMode('view');
// Note: Don't manually switch mode here - EditViewModal will handle it
// after refetch completes if waitForRefetch is enabled
setEditedRecipe({});
setEditedIngredients([]);
console.log('Recipe updated successfully');
} catch (error) {
console.error('Error updating recipe:', error);
@@ -297,6 +592,28 @@ const RecipesPage: React.FC = () => {
}
};
// Handle soft delete (archive)
const handleSoftDelete = async (recipeId: string) => {
try {
await archiveRecipeMutation.mutateAsync(recipeId);
console.log('Recipe archived successfully');
} catch (error) {
console.error('Error archiving recipe:', error);
throw error;
}
};
// Handle hard delete (permanent)
const handleHardDelete = async (recipeId: string) => {
try {
await deleteRecipeMutation.mutateAsync(recipeId);
console.log('Recipe deleted successfully');
} catch (error) {
console.error('Error deleting recipe:', error);
throw error;
}
};
// Get current value for field (edited value or original)
const getFieldValue = (originalValue: any, fieldKey: string) => {
return editedRecipe[fieldKey as keyof RecipeResponse] !== undefined
@@ -304,6 +621,23 @@ const RecipesPage: React.FC = () => {
: originalValue;
};
// Helper to display JSON fields in a readable format
const formatJsonField = (jsonData: any): string => {
if (!jsonData) return 'No especificado';
if (typeof jsonData === 'string') return jsonData;
if (typeof jsonData === 'object') {
// Extract common patterns
if (jsonData.steps) return jsonData.steps;
if (jsonData.checkpoints) return jsonData.checkpoints;
if (jsonData.issues) return jsonData.issues;
if (jsonData.allergens) return jsonData.allergens.join(', ');
if (jsonData.tags) return jsonData.tags.join(', ');
if (jsonData.info) return jsonData.info;
return JSON.stringify(jsonData, null, 2);
}
return String(jsonData);
};
// Get modal sections with editable fields
const getModalSections = () => {
if (!selectedRecipe) return [];
@@ -313,6 +647,32 @@ const RecipesPage: React.FC = () => {
title: 'Información Básica',
icon: ChefHat,
fields: [
{
label: 'Nombre',
value: getFieldValue(selectedRecipe.name, 'name'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Código de receta',
value: getFieldValue(selectedRecipe.recipe_code || 'Sin código', 'recipe_code'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Versión',
value: getFieldValue(selectedRecipe.version, 'version'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Descripción',
value: getFieldValue(selectedRecipe.description || 'Sin descripción', 'description'),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Categoría',
value: getFieldValue(selectedRecipe.category || 'Sin categoría', 'category'),
@@ -326,6 +686,12 @@ const RecipesPage: React.FC = () => {
] : undefined,
editable: true
},
{
label: 'Tipo de cocina',
value: getFieldValue(selectedRecipe.cuisine_type || 'No especificado', 'cuisine_type'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Dificultad',
value: modalMode === 'edit'
@@ -348,16 +714,28 @@ const RecipesPage: React.FC = () => {
options: modalMode === 'edit' ? [
{ value: 'draft', label: 'Borrador' },
{ value: 'active', label: 'Activo' },
{ value: 'archived', label: 'Archivado' }
{ value: 'testing', label: 'Testing' },
{ value: 'archived', label: 'Archivado' },
{ value: 'discontinued', label: 'Discontinuado' }
] : undefined,
highlight: selectedRecipe.status === 'active',
editable: true
},
{
label: 'Rendimiento',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')
: `${getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')} ${selectedRecipe.yield_unit}`,
value: getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Unidad de rendimiento',
value: getFieldValue(selectedRecipe.yield_unit, 'yield_unit'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Porciones',
value: getFieldValue(selectedRecipe.serves_count || 1, 'serves_count'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
}
@@ -385,6 +763,15 @@ const RecipesPage: React.FC = () => {
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
editable: modalMode === 'edit'
},
{
label: 'Tiempo de reposo',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.rest_time_minutes || 0, 'rest_time_minutes')
: formatTime(getFieldValue(selectedRecipe.rest_time_minutes || 0, 'rest_time_minutes')),
type: modalMode === 'edit' ? 'number' : 'text',
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
editable: modalMode === 'edit'
},
{
label: 'Tiempo total',
value: selectedRecipe.total_time_minutes ? formatTime(selectedRecipe.total_time_minutes) : 'No especificado',
@@ -419,16 +806,184 @@ const RecipesPage: React.FC = () => {
}
]
},
{
title: 'Configuración Especial',
icon: Star,
fields: [
{
label: 'Receta estacional',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.is_seasonal, 'is_seasonal')
: (selectedRecipe.is_seasonal ? 'Sí' : 'No'),
type: modalMode === 'edit' ? 'select' : 'text',
options: modalMode === 'edit' ? [
{ value: false, label: 'No' },
{ value: true, label: 'Sí' }
] : undefined,
editable: modalMode === 'edit'
},
{
label: 'Mes de inicio',
value: getFieldValue(selectedRecipe.season_start_month || 'No especificado', 'season_start_month'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Mes de fin',
value: getFieldValue(selectedRecipe.season_end_month || 'No especificado', 'season_end_month'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Receta estrella',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.is_signature_item, 'is_signature_item')
: (selectedRecipe.is_signature_item ? 'Sí' : 'No'),
type: modalMode === 'edit' ? 'select' : 'text',
options: modalMode === 'edit' ? [
{ value: false, label: 'No' },
{ value: true, label: 'Sí' }
] : undefined,
editable: modalMode === 'edit',
highlight: selectedRecipe.is_signature_item
}
]
},
{
title: 'Configuración de Producción',
icon: Settings,
fields: [
{
label: 'Multiplicador de lote',
value: getFieldValue(selectedRecipe.batch_size_multiplier || 1.0, 'batch_size_multiplier'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Tamaño mínimo de lote',
value: getFieldValue(selectedRecipe.minimum_batch_size || 'No especificado', 'minimum_batch_size'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Tamaño máximo de lote',
value: getFieldValue(selectedRecipe.maximum_batch_size || 'No especificado', 'maximum_batch_size'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Temperatura óptima',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.optimal_production_temperature || '', 'optimal_production_temperature')
: `${getFieldValue(selectedRecipe.optimal_production_temperature || 'No especificado', 'optimal_production_temperature')}${selectedRecipe.optimal_production_temperature ? '°C' : ''}`,
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Humedad óptima',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.optimal_humidity || '', 'optimal_humidity')
: `${getFieldValue(selectedRecipe.optimal_humidity || 'No especificado', 'optimal_humidity')}${selectedRecipe.optimal_humidity ? '%' : ''}`,
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
}
]
},
{
title: 'Instrucciones',
icon: FileText,
fields: [
{
label: 'Notas de preparación',
value: getFieldValue(selectedRecipe.preparation_notes || 'No especificado', 'preparation_notes'),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Instrucciones de almacenamiento',
value: getFieldValue(selectedRecipe.storage_instructions || 'No especificado', 'storage_instructions'),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Instrucciones de preparación',
value: modalMode === 'edit'
? formatJsonField(getFieldValue(selectedRecipe.instructions, 'instructions'))
: formatJsonField(selectedRecipe.instructions),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
}
]
},
{
title: 'Información Nutricional',
icon: Package,
fields: [
{
label: 'Información de alérgenos',
value: modalMode === 'edit'
? formatJsonField(getFieldValue(selectedRecipe.allergen_info, 'allergen_info'))
: formatJsonField(selectedRecipe.allergen_info),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Etiquetas dietéticas',
value: modalMode === 'edit'
? formatJsonField(getFieldValue(selectedRecipe.dietary_tags, 'dietary_tags'))
: formatJsonField(selectedRecipe.dietary_tags),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Información nutricional',
value: modalMode === 'edit'
? formatJsonField(getFieldValue(selectedRecipe.nutritional_info, 'nutritional_info'))
: formatJsonField(selectedRecipe.nutritional_info),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
}
]
},
{
title: 'Ingredientes',
icon: Package,
fields: [
{
label: 'Lista de ingredientes',
value: selectedRecipe.ingredients?.map(ing => `${ing.quantity} ${ing.unit} - ${ing.ingredient_id}`) || ['No especificados'],
type: 'list',
value: modalMode === 'edit'
? (() => {
const val = editedIngredients.length > 0 ? editedIngredients : selectedRecipe.ingredients || [];
console.log('[RecipesPage] Edit mode - Ingredients value:', val, 'editedIngredients.length:', editedIngredients.length);
return val;
})()
: (selectedRecipe.ingredients
?.sort((a, b) => a.ingredient_order - b.ingredient_order)
?.map(ing => {
const ingredientName = ingredientLookup[ing.ingredient_id] || ing.ingredient_id;
const optional = ing.is_optional ? ' (opcional)' : '';
const prep = ing.preparation_method ? ` - ${ing.preparation_method}` : '';
const notes = ing.ingredient_notes ? ` [${ing.ingredient_notes}]` : '';
return `${ing.quantity} ${ing.unit} de ${ingredientName}${optional}${prep}${notes}`;
}) || ['No especificados']),
type: modalMode === 'edit' ? 'component' as const : 'list' as const,
component: modalMode === 'edit' ? IngredientsEditComponent : undefined,
componentProps: modalMode === 'edit' ? {
availableIngredients,
unitOptions,
onChange: (newIngredients: RecipeIngredientResponse[]) => {
console.log('[RecipesPage] Ingredients onChange called with:', newIngredients);
setEditedIngredients(newIngredients);
}
} : undefined,
span: 2,
readonly: true // For now, ingredients editing can be complex, so we'll keep it read-only
editable: modalMode === 'edit'
}
]
},
@@ -553,13 +1108,9 @@ const RecipesPage: React.FC = () => {
metadata={[
`Tiempo: ${totalTime}`,
`Rendimiento: ${recipe.yield_quantity} ${recipe.yield_unit}`,
`${recipe.ingredients?.length || 0} ingredientes principales`
`Control de Calidad: ${getQualityIndicator(recipe)}`
]}
onClick={() => {
setSelectedRecipe(recipe);
setModalMode('view');
setShowForm(true);
}}
onClick={() => handleOpenRecipe(recipe.id)}
actions={[
// Primary action - View recipe details
{
@@ -567,21 +1118,17 @@ const RecipesPage: React.FC = () => {
icon: Eye,
variant: 'primary',
priority: 'primary',
onClick: () => {
setSelectedRecipe(recipe);
setModalMode('view');
setShowForm(true);
}
onClick: () => handleOpenRecipe(recipe.id)
},
// Secondary action - Edit recipe
// Delete action
{
label: 'Editar',
icon: Edit,
label: 'Eliminar',
icon: Trash2,
variant: 'danger',
priority: 'secondary',
onClick: () => {
setSelectedRecipe(recipe);
setModalMode('edit');
setShowForm(true);
setRecipeToDelete(recipe);
setShowDeleteModal(true);
}
}
]}
@@ -592,19 +1139,14 @@ const RecipesPage: React.FC = () => {
{/* Empty State */}
{filteredRecipes.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 recetas
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o crear una nueva receta
</p>
<Button onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Nueva Receta
</Button>
</div>
<EmptyState
icon={ChefHat}
title="No se encontraron recetas"
description="Intenta ajustar la búsqueda o crear una nueva receta"
actionLabel="Nueva Receta"
actionIcon={Plus}
onAction={() => setShowCreateModal(true)}
/>
)}
{/* Recipe Details Modal */}
@@ -616,12 +1158,17 @@ const RecipesPage: React.FC = () => {
setSelectedRecipe(null);
setModalMode('view');
setEditedRecipe({});
setEditedIngredients([]);
}}
mode={modalMode}
onModeChange={(newMode) => {
setModalMode(newMode);
if (newMode === 'view') {
setEditedRecipe({});
setEditedIngredients([]);
} else if (newMode === 'edit' && selectedRecipe) {
// Initialize edited ingredients when entering edit mode
setEditedIngredients(selectedRecipe.ingredients || []);
}
}}
title={selectedRecipe.name}
@@ -632,6 +1179,9 @@ const RecipesPage: React.FC = () => {
onFieldChange={handleFieldChange}
showDefaultActions={true}
onSave={handleSaveRecipe}
waitForRefetch={true}
isRefetching={isRefetchingRecipes}
onSaveComplete={handleRecipeSaveComplete}
/>
)}
@@ -652,6 +1202,30 @@ const RecipesPage: React.FC = () => {
isLoading={updateRecipeMutation.isPending}
/>
)}
{/* Delete Recipe Modal */}
<DeleteRecipeModal
isOpen={showDeleteModal}
onClose={() => {
setShowDeleteModal(false);
setRecipeToDelete(null);
}}
recipe={recipeToDelete}
onSoftDelete={handleSoftDelete}
onHardDelete={handleHardDelete}
isLoading={archiveRecipeMutation.isPending || deleteRecipeMutation.isPending}
/>
{/* Quality Configuration Prompt */}
{newlyCreatedRecipe && (
<QualityPromptDialog
isOpen={showQualityPrompt}
onClose={handleConfigureQualityLater}
onConfigureNow={handleConfigureQualityNow}
onConfigureLater={handleConfigureQualityLater}
recipeName={newlyCreatedRecipe.name}
/>
)}
</div>
);
};

View File

@@ -1,13 +1,15 @@
import React, { useState } from 'react';
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader } from 'lucide-react';
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader, Trash2 } from 'lucide-react';
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, AddModal, SearchAndFilter, DialogModal, type FilterConfig, EmptyState } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers';
import { useSuppliers, useSupplierStatistics } from '../../../../api/hooks/suppliers';
import { useSuppliers, useSupplierStatistics, useCreateSupplier, useUpdateSupplier, useApproveSupplier, useDeleteSupplier, useHardDeleteSupplier } from '../../../../api/hooks/suppliers';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { useTranslation } from 'react-i18next';
import { statusColors } from '../../../../styles/colors';
import { DeleteSupplierModal } from '../../../../components/domain/suppliers';
const SuppliersPage: React.FC = () => {
const [activeTab] = useState('all');
@@ -17,7 +19,10 @@ const SuppliersPage: React.FC = () => {
const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedSupplier, setSelectedSupplier] = useState<any>(null);
const [isCreating, setIsCreating] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showApprovalModal, setShowApprovalModal] = useState(false);
const [supplierToApprove, setSupplierToApprove] = useState<any>(null);
// Get tenant ID from tenant store (preferred) or auth user (fallback)
const currentTenant = useCurrentTenant();
@@ -44,6 +49,22 @@ const SuppliersPage: React.FC = () => {
const suppliers = suppliersData || [];
const { t } = useTranslation(['suppliers', 'common']);
// Mutation hooks
const createSupplierMutation = useCreateSupplier();
const updateSupplierMutation = useUpdateSupplier();
const approveSupplierMutation = useApproveSupplier();
const softDeleteMutation = useDeleteSupplier();
const hardDeleteMutation = useHardDeleteSupplier();
// Delete handlers
const handleSoftDelete = async (supplierId: string) => {
await softDeleteMutation.mutateAsync({ tenantId, supplierId });
};
const handleHardDelete = async (supplierId: string) => {
return await hardDeleteMutation.mutateAsync({ tenantId, supplierId });
};
const getSupplierStatusConfig = (status: SupplierStatus) => {
const statusConfig = {
[SupplierStatus.ACTIVE]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: CheckCircle },
@@ -158,7 +179,7 @@ const SuppliersPage: React.FC = () => {
return (
<div className="space-y-6">
<PageHeader
<PageHeader
title="Gestión de Proveedores"
description="Administra y supervisa todos los proveedores de la panadería"
actions={[
@@ -167,26 +188,7 @@ const SuppliersPage: React.FC = () => {
label: "Nuevo Proveedor",
variant: "primary" as const,
icon: Plus,
onClick: () => {
setSelectedSupplier({
name: '',
contact_person: '',
email: '',
phone: '',
city: '',
country: '',
supplier_code: '',
supplier_type: SupplierType.INGREDIENTS,
payment_terms: PaymentTerms.NET_30,
standard_lead_time: 3,
minimum_order_amount: 0,
credit_limit: 0,
currency: 'EUR'
});
setIsCreating(true);
setModalMode('edit');
setShowForm(true);
}
onClick: () => setShowAddModal(true)
}
]}
/>
@@ -243,7 +245,7 @@ const SuppliersPage: React.FC = () => {
title={supplier.name}
subtitle={`${getSupplierTypeText(supplier.supplier_type)}${supplier.city || 'Sin ubicación'}`}
primaryValue={supplier.standard_lead_time || 0}
primaryValueLabel="días"
primaryValueLabel="días entrega"
secondaryInfo={{
label: 'Pedido Min.',
value: `${formatters.compact(supplier.minimum_order_amount || 0)}`
@@ -256,7 +258,6 @@ const SuppliersPage: React.FC = () => {
]}
onClick={() => {
setSelectedSupplier(supplier);
setIsCreating(false);
setModalMode('view');
setShowForm(true);
}}
@@ -269,23 +270,41 @@ const SuppliersPage: React.FC = () => {
priority: 'primary',
onClick: () => {
setSelectedSupplier(supplier);
setIsCreating(false);
setModalMode('view');
setShowForm(true);
}
},
// Secondary action - Edit supplier
{
label: 'Editar',
icon: Edit,
priority: 'secondary',
onClick: () => {
setSelectedSupplier(supplier);
setIsCreating(false);
setModalMode('edit');
setShowForm(true);
}
}
// Approval action - Only show for pending suppliers + admin/super_admin
...(supplier.status === SupplierStatus.PENDING_APPROVAL &&
(user?.role === 'admin' || user?.role === 'super_admin')
? [{
label: t('suppliers:actions.approve'),
icon: CheckCircle,
variant: 'primary' as const,
priority: 'primary' as const,
highlighted: true,
onClick: () => {
setSupplierToApprove(supplier);
setShowApprovalModal(true);
}
}]
: []
),
// Delete action - Only show for admin/super_admin
...(user?.role === 'admin' || user?.role === 'super_admin'
? [{
label: t('suppliers:actions.delete'),
icon: Trash2,
variant: 'outline' as const,
priority: 'secondary' as const,
destructive: true,
onClick: () => {
setSelectedSupplier(supplier);
setShowDeleteModal(true);
}
}]
: []
)
]}
/>
);
@@ -294,26 +313,236 @@ const SuppliersPage: React.FC = () => {
{/* Empty State */}
{filteredSuppliers.length === 0 && (
<div className="text-center py-12">
<Building2 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 proveedores
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o crear un nuevo proveedor
</p>
<Button onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Nuevo Proveedor
</Button>
</div>
<EmptyState
icon={Building2}
title="No se encontraron proveedores"
description="Intenta ajustar la búsqueda o crear un nuevo proveedor"
actionLabel="Nuevo Proveedor"
actionIcon={Plus}
onAction={() => setShowAddModal(true)}
/>
)}
{/* Add Supplier Modal */}
<AddModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
title={t('suppliers:actions.new_supplier', 'Nuevo Proveedor')}
subtitle={t('suppliers:actions.create_new_supplier', 'Crear nuevo proveedor')}
size="lg"
sections={[
{
title: t('suppliers:sections.contact_info'),
icon: Users,
fields: [
{
label: t('common:fields.name'),
name: 'name',
type: 'text',
required: true,
placeholder: t('suppliers:placeholders.name')
},
{
label: t('common:fields.contact_person'),
name: 'contact_person',
type: 'text',
placeholder: t('suppliers:placeholders.contact_person')
},
{
label: t('common:fields.email'),
name: 'email',
type: 'email',
placeholder: t('common:fields.email_placeholder')
},
{
label: t('common:fields.phone'),
name: 'phone',
type: 'tel',
placeholder: t('common:fields.phone_placeholder')
},
{
label: t('suppliers:labels.mobile'),
name: 'mobile',
type: 'tel',
placeholder: t('suppliers:placeholders.mobile')
},
{
label: t('suppliers:labels.website'),
name: 'website',
type: 'text',
placeholder: t('suppliers:placeholders.website')
}
]
},
{
title: t('suppliers:sections.address_info'),
icon: Building2,
fields: [
{
label: t('suppliers:labels.address_line1'),
name: 'address_line1',
type: 'text',
placeholder: t('suppliers:placeholders.address_line1')
},
{
label: t('suppliers:labels.address_line2'),
name: 'address_line2',
type: 'text',
placeholder: t('suppliers:placeholders.address_line2')
},
{
label: t('common:fields.city'),
name: 'city',
type: 'text',
placeholder: t('common:fields.city')
},
{
label: t('suppliers:labels.state_province'),
name: 'state_province',
type: 'text',
placeholder: t('suppliers:placeholders.state_province')
},
{
label: t('suppliers:labels.postal_code'),
name: 'postal_code',
type: 'text',
placeholder: t('suppliers:placeholders.postal_code')
},
{
label: t('common:fields.country'),
name: 'country',
type: 'text',
placeholder: t('common:fields.country')
}
]
},
{
title: t('suppliers:sections.commercial_info'),
icon: Euro,
fields: [
{
label: t('suppliers:labels.supplier_code'),
name: 'supplier_code',
type: 'text',
placeholder: t('suppliers:placeholders.supplier_code')
},
{
label: t('suppliers:labels.supplier_type'),
name: 'supplier_type',
type: 'select',
required: true,
defaultValue: SupplierType.INGREDIENTS,
options: Object.values(SupplierType).map(value => ({
value,
label: t(`suppliers:types.${value.toLowerCase()}`)
}))
},
{
label: t('suppliers:labels.payment_terms'),
name: 'payment_terms',
type: 'select',
defaultValue: PaymentTerms.NET_30,
options: Object.values(PaymentTerms).map(value => ({
value,
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
}))
},
{
label: t('suppliers:labels.currency'),
name: 'currency',
type: 'select',
defaultValue: 'EUR',
options: [
{ value: 'EUR', label: t('suppliers:currencies.EUR') },
{ value: 'USD', label: t('suppliers:currencies.USD') },
{ value: 'GBP', label: t('suppliers:currencies.GBP') }
]
},
{
label: t('suppliers:labels.lead_time'),
name: 'standard_lead_time',
type: 'number',
defaultValue: 3,
placeholder: '3'
},
{
label: t('suppliers:labels.minimum_order'),
name: 'minimum_order_amount',
type: 'currency',
defaultValue: 0,
placeholder: '0.00'
},
{
label: t('suppliers:labels.credit_limit'),
name: 'credit_limit',
type: 'currency',
defaultValue: 0,
placeholder: '0.00'
}
]
},
{
title: t('suppliers:sections.additional_info'),
icon: Building2,
fields: [
{
label: t('suppliers:labels.tax_id'),
name: 'tax_id',
type: 'text',
placeholder: t('suppliers:placeholders.tax_id')
},
{
label: t('suppliers:labels.registration_number'),
name: 'registration_number',
type: 'text',
placeholder: t('suppliers:placeholders.registration_number')
},
{
label: t('suppliers:labels.delivery_area'),
name: 'delivery_area',
type: 'text',
placeholder: t('suppliers:placeholders.delivery_area')
}
]
}
]}
onSave={async (formData) => {
await createSupplierMutation.mutateAsync({
tenantId,
supplierData: {
name: formData.name,
supplier_code: formData.supplier_code || null,
tax_id: formData.tax_id || null,
registration_number: formData.registration_number || null,
supplier_type: formData.supplier_type || SupplierType.INGREDIENTS,
contact_person: formData.contact_person || null,
email: formData.email || null,
phone: formData.phone || null,
mobile: formData.mobile || null,
website: formData.website || null,
address_line1: formData.address_line1 || null,
address_line2: formData.address_line2 || null,
city: formData.city || null,
state_province: formData.state_province || null,
postal_code: formData.postal_code || null,
country: formData.country || null,
payment_terms: formData.payment_terms || PaymentTerms.NET_30,
credit_limit: formData.credit_limit || null,
currency: formData.currency || 'EUR',
standard_lead_time: formData.standard_lead_time || 3,
minimum_order_amount: formData.minimum_order_amount || null,
delivery_area: formData.delivery_area || null,
notes: formData.notes || null
}
});
}}
/>
{/* Supplier Details Modal */}
{showForm && selectedSupplier && (() => {
const sections = [
{
title: 'Información de Contacto',
title: t('suppliers:sections.contact_info'),
icon: Users,
fields: [
{
@@ -346,6 +575,40 @@ const SuppliersPage: React.FC = () => {
editable: true,
placeholder: t('common:fields.phone_placeholder')
},
{
label: t('suppliers:labels.mobile'),
value: selectedSupplier.mobile || '',
type: 'tel' as const,
editable: true,
placeholder: t('suppliers:placeholders.mobile')
},
{
label: t('suppliers:labels.website'),
value: selectedSupplier.website || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.website')
}
]
},
{
title: t('suppliers:sections.address_info'),
icon: Building2,
fields: [
{
label: t('suppliers:labels.address_line1'),
value: selectedSupplier.address_line1 || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.address_line1')
},
{
label: t('suppliers:labels.address_line2'),
value: selectedSupplier.address_line2 || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.address_line2')
},
{
label: t('common:fields.city'),
value: selectedSupplier.city || '',
@@ -353,6 +616,20 @@ const SuppliersPage: React.FC = () => {
editable: true,
placeholder: t('common:fields.city')
},
{
label: t('suppliers:labels.state_province'),
value: selectedSupplier.state_province || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.state_province')
},
{
label: t('suppliers:labels.postal_code'),
value: selectedSupplier.postal_code || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.postal_code')
},
{
label: t('common:fields.country'),
value: selectedSupplier.country || '',
@@ -363,8 +640,8 @@ const SuppliersPage: React.FC = () => {
]
},
{
title: 'Información Comercial',
icon: Building2,
title: t('suppliers:sections.commercial_info'),
icon: Euro,
fields: [
{
label: t('suppliers:labels.supplier_code'),
@@ -376,11 +653,12 @@ const SuppliersPage: React.FC = () => {
},
{
label: t('suppliers:labels.supplier_type'),
value: modalMode === 'view'
value: modalMode === 'view'
? getSupplierTypeText(selectedSupplier.supplier_type || SupplierType.INGREDIENTS)
: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
type: modalMode === 'view' ? 'text' as const : 'select' as const,
editable: true,
required: true,
options: modalMode === 'edit' ? Object.values(SupplierType).map(value => ({
value,
label: t(`suppliers:types.${value.toLowerCase()}`)
@@ -388,7 +666,7 @@ const SuppliersPage: React.FC = () => {
},
{
label: t('suppliers:labels.payment_terms'),
value: modalMode === 'view'
value: modalMode === 'view'
? getPaymentTermsText(selectedSupplier.payment_terms || PaymentTerms.NET_30)
: selectedSupplier.payment_terms || PaymentTerms.NET_30,
type: modalMode === 'view' ? 'text' as const : 'select' as const,
@@ -398,6 +676,19 @@ const SuppliersPage: React.FC = () => {
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
})) : undefined
},
{
label: t('suppliers:labels.currency'),
value: modalMode === 'view'
? t(`suppliers:currencies.${selectedSupplier.currency || 'EUR'}`)
: selectedSupplier.currency || 'EUR',
type: modalMode === 'view' ? 'text' as const : 'select' as const,
editable: true,
options: modalMode === 'edit' ? [
{ value: 'EUR', label: t('suppliers:currencies.EUR') },
{ value: 'USD', label: t('suppliers:currencies.USD') },
{ value: 'GBP', label: t('suppliers:currencies.GBP') }
] : undefined
},
{
label: t('suppliers:labels.lead_time'),
value: selectedSupplier.standard_lead_time || 3,
@@ -422,16 +713,37 @@ const SuppliersPage: React.FC = () => {
]
},
{
title: t('suppliers:sections.performance'),
icon: Euro,
title: t('suppliers:sections.additional_info'),
icon: Building2,
fields: [
{
label: t('suppliers:labels.currency'),
value: selectedSupplier.currency || 'EUR',
label: t('suppliers:labels.tax_id'),
value: selectedSupplier.tax_id || '',
type: 'text' as const,
editable: true,
placeholder: 'EUR'
placeholder: t('suppliers:placeholders.tax_id')
},
{
label: t('suppliers:labels.registration_number'),
value: selectedSupplier.registration_number || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.registration_number')
},
{
label: t('suppliers:labels.delivery_area'),
value: selectedSupplier.delivery_area || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.delivery_area')
}
]
},
// Performance section
{
title: t('suppliers:sections.performance'),
icon: CheckCircle,
fields: [
{
label: t('suppliers:labels.created_date'),
value: selectedSupplier.created_at,
@@ -467,19 +779,56 @@ const SuppliersPage: React.FC = () => {
setShowForm(false);
setSelectedSupplier(null);
setModalMode('view');
setIsCreating(false);
}}
mode={modalMode}
onModeChange={setModalMode}
title={isCreating ? 'Nuevo Proveedor' : selectedSupplier.name || 'Proveedor'}
subtitle={isCreating ? 'Crear nuevo proveedor' : `Proveedor ${selectedSupplier.supplier_code || ''}`}
statusIndicator={isCreating ? undefined : getSupplierStatusConfig(selectedSupplier.status)}
title={selectedSupplier.name || 'Proveedor'}
subtitle={`Proveedor ${selectedSupplier.supplier_code || ''}`}
statusIndicator={getSupplierStatusConfig(selectedSupplier.status)}
size="lg"
sections={sections}
showDefaultActions={modalMode === 'edit'}
showDefaultActions={true}
onSave={async () => {
// TODO: Implement save functionality
console.log('Saving supplier:', selectedSupplier);
try {
// Update existing supplier
await updateSupplierMutation.mutateAsync({
tenantId,
supplierId: selectedSupplier.id,
updateData: {
name: selectedSupplier.name,
supplier_code: selectedSupplier.supplier_code || null,
tax_id: selectedSupplier.tax_id || null,
registration_number: selectedSupplier.registration_number || null,
supplier_type: selectedSupplier.supplier_type,
contact_person: selectedSupplier.contact_person || null,
email: selectedSupplier.email || null,
phone: selectedSupplier.phone || null,
mobile: selectedSupplier.mobile || null,
website: selectedSupplier.website || null,
address_line1: selectedSupplier.address_line1 || null,
address_line2: selectedSupplier.address_line2 || null,
city: selectedSupplier.city || null,
state_province: selectedSupplier.state_province || null,
postal_code: selectedSupplier.postal_code || null,
country: selectedSupplier.country || null,
payment_terms: selectedSupplier.payment_terms,
credit_limit: selectedSupplier.credit_limit || null,
currency: selectedSupplier.currency || 'EUR',
standard_lead_time: selectedSupplier.standard_lead_time || 3,
minimum_order_amount: selectedSupplier.minimum_order_amount || null,
delivery_area: selectedSupplier.delivery_area || null,
notes: selectedSupplier.notes || null
}
});
// Close modal on success
setShowForm(false);
setSelectedSupplier(null);
setModalMode('view');
} catch (error) {
console.error('Error saving supplier:', error);
// Error will be handled by the modal's error display
throw error;
}
}}
onFieldChange={(sectionIndex, fieldIndex, value) => {
// Update the selectedSupplier state when fields change
@@ -493,15 +842,24 @@ const SuppliersPage: React.FC = () => {
[t('common:fields.contact_person')]: 'contact_person',
[t('common:fields.email')]: 'email',
[t('common:fields.phone')]: 'phone',
[t('suppliers:labels.mobile')]: 'mobile',
[t('suppliers:labels.website')]: 'website',
[t('suppliers:labels.address_line1')]: 'address_line1',
[t('suppliers:labels.address_line2')]: 'address_line2',
[t('common:fields.city')]: 'city',
[t('suppliers:labels.state_province')]: 'state_province',
[t('suppliers:labels.postal_code')]: 'postal_code',
[t('common:fields.country')]: 'country',
[t('suppliers:labels.supplier_code')]: 'supplier_code',
[t('suppliers:labels.supplier_type')]: 'supplier_type',
[t('suppliers:labels.payment_terms')]: 'payment_terms',
[t('suppliers:labels.currency')]: 'currency',
[t('suppliers:labels.lead_time')]: 'standard_lead_time',
[t('suppliers:labels.minimum_order')]: 'minimum_order_amount',
[t('suppliers:labels.credit_limit')]: 'credit_limit',
[t('suppliers:labels.currency')]: 'currency',
[t('suppliers:labels.tax_id')]: 'tax_id',
[t('suppliers:labels.registration_number')]: 'registration_number',
[t('suppliers:labels.delivery_area')]: 'delivery_area',
[t('suppliers:labels.notes')]: 'notes'
};
@@ -514,6 +872,76 @@ const SuppliersPage: React.FC = () => {
/>
);
})()}
{/* Delete Supplier Modal */}
<DeleteSupplierModal
isOpen={showDeleteModal}
onClose={() => {
setShowDeleteModal(false);
setSelectedSupplier(null);
}}
supplier={selectedSupplier}
onSoftDelete={handleSoftDelete}
onHardDelete={handleHardDelete}
isLoading={softDeleteMutation.isPending || hardDeleteMutation.isPending}
/>
{/* Approval Confirmation Modal */}
<DialogModal
isOpen={showApprovalModal}
onClose={() => {
setShowApprovalModal(false);
setSupplierToApprove(null);
}}
type="confirm"
title={t('suppliers:confirm.approve_title', 'Aprobar Proveedor')}
message={
supplierToApprove ? (
<div className="space-y-3">
<p className="text-[var(--text-primary)]">
{t('suppliers:confirm.approve_message', '¿Estás seguro de que deseas aprobar este proveedor?')}
</p>
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg border border-[var(--border-primary)]">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-[var(--color-primary)]" />
<span className="font-semibold text-[var(--text-primary)]">{supplierToApprove.name}</span>
</div>
{supplierToApprove.supplier_code && (
<p className="text-sm text-[var(--text-secondary)]">
{t('suppliers:labels.supplier_code')}: {supplierToApprove.supplier_code}
</p>
)}
{supplierToApprove.email && (
<p className="text-sm text-[var(--text-secondary)]">
{t('common:fields.email')}: {supplierToApprove.email}
</p>
)}
</div>
</div>
<p className="text-sm text-[var(--text-secondary)]">
{t('suppliers:confirm.approve_description', 'Una vez aprobado, el proveedor estará activo y podrá ser utilizado para realizar pedidos.')}
</p>
</div>
) : null
}
confirmLabel={t('suppliers:actions.approve', 'Aprobar')}
cancelLabel={t('common:modals.actions.cancel', 'Cancelar')}
onConfirm={async () => {
if (supplierToApprove) {
try {
await approveSupplierMutation.mutateAsync({
tenantId,
supplierId: supplierToApprove.id,
approvalData: { action: 'approve' }
});
} catch (error) {
console.error('Error approving supplier:', error);
}
}
}}
loading={approveSupplierMutation.isPending}
/>
</div>
);
};

View File

@@ -23,10 +23,9 @@ import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { useAuthUser, useAuthStore, useAuthActions } from '../../../../stores/auth.store';
import { useAuthUser, useAuthActions } from '../../../../stores/auth.store';
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
import { useCurrentTenant } from '../../../../stores';
import { subscriptionService } from '../../../../api';
// Import the communication preferences component
import CommunicationPreferences, { type NotificationPreferences } from './CommunicationPreferences';
@@ -52,7 +51,6 @@ const NewProfileSettingsPage: React.FC = () => {
const navigate = useNavigate();
const { addToast } = useToast();
const user = useAuthUser();
const token = useAuthStore((state) => state.token);
const { logout } = useAuthActions();
const currentTenant = useCurrentTenant();
@@ -72,7 +70,6 @@ const NewProfileSettingsPage: React.FC = () => {
const [deletePassword, setDeletePassword] = useState('');
const [deleteReason, setDeleteReason] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [subscriptionStatus, setSubscriptionStatus] = useState<any>(null);
const [profileData, setProfileData] = useState<ProfileFormData>({
first_name: '',
@@ -106,22 +103,8 @@ const NewProfileSettingsPage: React.FC = () => {
}
}, [profile]);
// Load subscription status
React.useEffect(() => {
const loadSubscriptionStatus = async () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (tenantId) {
try {
const status = await subscriptionService.getSubscriptionStatus(tenantId);
setSubscriptionStatus(status);
} catch (error) {
console.error('Failed to load subscription status:', error);
}
}
};
loadSubscriptionStatus();
}, [currentTenant, user]);
// Subscription status is not needed on the profile page
// It's already shown in the subscription tab of the main ProfilePage
const languageOptions = [
{ value: 'es', label: 'Español' },
@@ -249,17 +232,11 @@ const NewProfileSettingsPage: React.FC = () => {
const handleDataExport = async () => {
setIsExporting(true);
try {
const response = await fetch('/api/v1/users/me/export', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const { authService } = await import('../../../../api');
const exportData = await authService.exportMyData();
if (!response.ok) {
throw new Error('Failed to export data');
}
const blob = await response.blob();
// Convert to blob and download
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -290,23 +267,8 @@ const NewProfileSettingsPage: React.FC = () => {
setIsDeleting(true);
try {
const response = await fetch('/api/v1/users/me/delete/request', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
confirm_email: deleteConfirmEmail,
password: deletePassword,
reason: deleteReason
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to delete account');
}
const { authService } = await import('../../../../api');
await authService.deleteAccount(deleteConfirmEmail, deletePassword, deleteReason);
addToast(t('common.success'), { type: 'success' });
@@ -717,22 +679,6 @@ const NewProfileSettingsPage: React.FC = () => {
</div>
<div className="space-y-4">
{subscriptionStatus && subscriptionStatus.status === 'active' && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold text-yellow-900 dark:text-yellow-100 mb-1">
Suscripción Activa Detectada
</p>
<p className="text-yellow-800 dark:text-yellow-200">
Tienes una suscripción activa que se cancelará
</p>
</div>
</div>
</div>
)}
<Input
label="Confirma tu email"
type="email"

View File

@@ -1,14 +1,16 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-react';
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck, Eye, Activity } from 'lucide-react';
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig, EmptyState, EditViewModal } from '../../../../components/ui';
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
import { PageHeader } from '../../../../components/layout';
import { useTeamMembers, useAddTeamMember, useAddTeamMemberWithUserCreation, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
import { useUserActivity } from '../../../../api/hooks/user';
import { userService } from '../../../../api/services/user';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
import { TENANT_ROLES } from '../../../../types/roles';
import { TENANT_ROLES, type TenantRole } from '../../../../types/roles';
import { subscriptionService } from '../../../../api/services/subscription';
const TeamPage: React.FC = () => {
@@ -38,7 +40,18 @@ const TeamPage: React.FC = () => {
const [selectedRole, setSelectedRole] = useState('all');
const [showAddForm, setShowAddForm] = useState(false);
const [selectedUserToAdd, setSelectedUserToAdd] = useState('');
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState<string>(TENANT_ROLES.MEMBER);
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState<TenantRole>(TENANT_ROLES.MEMBER);
// Modal state for team member details
const [selectedMember, setSelectedMember] = useState<any>(null);
const [showMemberModal, setShowMemberModal] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [memberFormData, setMemberFormData] = useState<any>({});
// Modal state for activity view
const [showActivityModal, setShowActivityModal] = useState(false);
const [selectedMemberActivity, setSelectedMemberActivity] = useState<any>(null);
const [activityLoading, setActivityLoading] = useState(false);
// Enhanced team members that includes owner information
@@ -96,6 +109,21 @@ const TeamPage: React.FC = () => {
owners: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.OWNER).length,
admins: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.ADMIN).length,
members: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.MEMBER).length,
uniqueRoles: new Set(enhancedTeamMembers.map(m => m.role)).size,
averageDaysInTeam: (() => {
// Only calculate for non-owner members to avoid skewing the average
// Owners are added as placeholders with tenant creation date which skews the average
const nonOwnerMembers = enhancedTeamMembers.filter(m => m.role !== TENANT_ROLES.OWNER);
if (nonOwnerMembers.length === 0) return 0;
const totalDays = nonOwnerMembers.reduce((sum, m) => {
const joinedDate = m.joined_at ? new Date(m.joined_at) : new Date();
const days = Math.floor((Date.now() - joinedDate.getTime()) / (1000 * 60 * 24));
return sum + days;
}, 0);
return Math.round(totalDays / nonOwnerMembers.length);
})()
};
@@ -151,21 +179,24 @@ const TeamPage: React.FC = () => {
};
};
const getMemberActions = (member: any) => {
const getMemberActions = (member: any) => {
const actions = [];
// Primary action - View details (always available)
// This will be implemented in the future to show detailed member info modal
// For now, we can comment it out as there's no modal yet
// actions.push({
// label: 'Ver Detalles',
// icon: Eye,
// priority: 'primary' as const,
// onClick: () => {
// // TODO: Implement member details modal
// console.log('View member details:', member.user_id);
// },
// });
// Primary action - View profile details
actions.push({
label: 'Ver Perfil',
icon: Eye,
onClick: () => handleViewMemberDetails(member),
priority: 'primary' as const
});
// Secondary action - View activity
actions.push({
label: 'Ver Actividad',
icon: Activity,
onClick: () => handleViewActivity(member),
priority: 'secondary' as const
});
// Contextual role change actions (only for non-owners and if user can manage team)
if (canManageTeam && member.role !== TENANT_ROLES.OWNER) {
@@ -204,7 +235,7 @@ const TeamPage: React.FC = () => {
// Remove member action (only for owners and non-owner members)
if (isOwner && member.role !== TENANT_ROLES.OWNER) {
actions.push({
label: 'Remover',
label: 'Remover Miembro',
icon: Trash2,
onClick: () => {
if (confirm('¿Estás seguro de que deseas remover este miembro?')) {
@@ -217,6 +248,72 @@ const TeamPage: React.FC = () => {
}
return actions;
};
const handleViewMemberDetails = (member: any) => {
setSelectedMember(member);
setMemberFormData({
full_name: member.user?.full_name || member.user_full_name || '',
email: member.user?.email || member.user_email || '',
phone: member.user?.phone || '',
role: member.role,
language: member.user?.language || 'es',
timezone: member.user?.timezone || 'Europe/Madrid',
is_active: member.is_active,
joined_at: member.joined_at
});
setModalMode('view');
setShowMemberModal(true);
};
const handleEditMember = () => {
setModalMode('edit');
};
const handleSaveMember = async () => {
// TODO: Implement member update logic
console.log('Saving member:', memberFormData);
setShowMemberModal(false);
};
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
const fieldMap: Record<number, string> = {
0: 'full_name',
1: 'email',
2: 'phone',
3: 'role',
4: 'language',
5: 'timezone',
6: 'is_active'
};
const fieldName = fieldMap[fieldIndex];
if (fieldName) {
// Convert string boolean values back to actual booleans for 'is_active' field
const processedValue = fieldName === 'is_active' ? value === 'true' : value;
setMemberFormData((prev: any) => ({
...prev,
[fieldName]: processedValue
}));
}
};
const handleViewActivity = async (member: any) => {
const userId = member.user_id;
if (!userId) return;
try {
setActivityLoading(true);
const activityData = await userService.getUserActivity(userId);
setSelectedMemberActivity(activityData);
setShowActivityModal(true);
} catch (error) {
console.error('Error fetching user activity:', error);
addToast('Error al cargar la actividad del usuario', { type: 'error' });
} finally {
setActivityLoading(false);
}
};
const filteredMembers = enhancedTeamMembers.filter(member => {
@@ -311,6 +408,7 @@ const TeamPage: React.FC = () => {
canManageTeam ? [{
id: 'add-member',
label: 'Agregar Miembro',
variant: "primary" as const,
icon: Plus,
onClick: () => setShowAddForm(true)
}] : undefined
@@ -343,9 +441,21 @@ const TeamPage: React.FC = () => {
value: teamStats.owners,
icon: Crown,
variant: "purple"
},
{
title: "Roles Únicos",
value: teamStats.uniqueRoles,
icon: Users,
variant: "info"
},
{
title: "Días Promedio",
value: teamStats.averageDaysInTeam,
icon: UserCheck,
variant: "info"
}
]}
columns={4}
columns={3}
gap="md"
/>
@@ -414,29 +524,18 @@ const TeamPage: React.FC = () => {
</div>
{filteredMembers.length === 0 && (
<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'
<EmptyState
icon={Users}
title="No se encontraron miembros"
description={
searchTerm || selectedRole !== 'all'
? "No hay miembros que coincidan con los filtros seleccionados"
: "Este tenant aún no tiene miembros del equipo"
}
</p>
{canManageTeam && (
<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>
}
actionLabel={canManageTeam ? "Agregar Primer Miembro" : undefined}
actionIcon={canManageTeam ? Plus : undefined}
onAction={canManageTeam ? () => setShowAddForm(true) : undefined}
/>
)}
{/* Add Member Modal - Using StatusModal */}
@@ -452,7 +551,7 @@ const TeamPage: React.FC = () => {
try {
// Check subscription limits before adding member
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'users', 1);
const usageCheck = await subscriptionService.checkQuotaLimit(tenantId, 'users', 1);
if (!usageCheck.allowed) {
const errorMessage = usageCheck.message ||
@@ -461,6 +560,10 @@ const TeamPage: React.FC = () => {
throw new Error(errorMessage);
}
// The AddTeamMemberModal returns a string role, but it's always one of the tenant roles
// Since the modal only allows MEMBER, ADMIN, VIEWER (no OWNER), we can safely cast it
const role = userData.role as typeof TENANT_ROLES.MEMBER | typeof TENANT_ROLES.ADMIN | typeof TENANT_ROLES.VIEWER;
// Use appropriate mutation based on whether we're creating a user
if (userData.createUser) {
await addMemberWithUserMutation.mutateAsync({
@@ -471,7 +574,7 @@ const TeamPage: React.FC = () => {
full_name: userData.fullName!,
password: userData.password!,
phone: userData.phone,
role: userData.role,
role,
language: 'es',
timezone: 'Europe/Madrid'
}
@@ -481,7 +584,7 @@ const TeamPage: React.FC = () => {
await addMemberMutation.mutateAsync({
tenantId,
userId: userData.userId!,
role: userData.role,
role,
});
addToast('Miembro agregado exitosamente', { type: 'success' });
}
@@ -503,8 +606,197 @@ const TeamPage: React.FC = () => {
}}
availableUsers={[]}
/>
{/* Team Member Details Modal */}
<EditViewModal
isOpen={showMemberModal}
onClose={() => setShowMemberModal(false)}
mode={modalMode}
onModeChange={setModalMode}
title={memberFormData.full_name || 'Miembro del Equipo'}
subtitle={memberFormData.email}
statusIndicator={selectedMember ? getMemberStatusConfig(selectedMember) : undefined}
sections={[
{
title: 'Información Personal',
icon: Users,
fields: [
{
label: 'Nombre Completo',
value: memberFormData.full_name,
type: 'text',
editable: true,
required: true,
placeholder: 'Introduce el nombre completo',
span: 2
},
{
label: 'Email',
value: memberFormData.email,
type: 'email',
editable: true,
required: true,
placeholder: 'Introduce el email',
span: 2
},
{
label: 'Teléfono',
value: memberFormData.phone,
type: 'tel',
editable: true,
placeholder: 'Introduce el teléfono',
span: 2
}
]
},
{
title: 'Configuración de Cuenta',
icon: Shield,
fields: [
{
label: 'Rol',
value: getRoleLabel(memberFormData.role),
type: 'select',
editable: modalMode === 'edit',
options: [
{ label: 'Miembro - Acceso estándar', value: TENANT_ROLES.MEMBER },
{ label: 'Administrador - Gestión de equipo', value: TENANT_ROLES.ADMIN },
{ label: 'Observador - Solo lectura', value: TENANT_ROLES.VIEWER }
],
span: 2
},
{
label: 'Idioma',
value: memberFormData.language?.toUpperCase() || 'ES',
type: 'select',
editable: modalMode === 'edit',
options: [
{ label: 'Español', value: 'es' },
{ label: 'English', value: 'en' },
{ label: 'Euskera', value: 'eu' }
],
span: 2
},
{
label: 'Zona Horaria',
value: memberFormData.timezone || 'Europe/Madrid',
type: 'select',
editable: modalMode === 'edit',
options: [
{ label: 'Madrid (CET)', value: 'Europe/Madrid' },
{ label: 'London (GMT)', value: 'Europe/London' },
{ label: 'New York (EST)', value: 'America/New_York' }
],
span: 2
},
{
label: 'Estado',
value: memberFormData.is_active ? 'Activo' : 'Inactivo',
type: 'select',
editable: modalMode === 'edit',
options: [
{ label: 'Activo', value: 'true' },
{ label: 'Inactivo', value: 'false' }
],
span: 2
}
]
},
{
title: 'Detalles del Equipo',
icon: UserCheck,
fields: [
{
label: 'Fecha de Ingreso',
value: memberFormData.joined_at,
type: 'date',
editable: false,
span: 2
},
{
label: 'Días en el Equipo',
value: selectedMember ? Math.floor((Date.now() - new Date(selectedMember.joined_at).getTime()) / (1000 * 60 * 60 * 24)) : 0,
type: 'number',
editable: false,
span: 2
},
{
label: 'ID de Usuario',
value: selectedMember?.user_id || 'N/A',
type: 'text',
editable: false,
span: 2
}
]
}
]}
onFieldChange={handleFieldChange}
onEdit={handleEditMember}
onSave={handleSaveMember}
size="lg"
/>
{/* Activity Modal */}
<EditViewModal
isOpen={showActivityModal}
onClose={() => setShowActivityModal(false)}
mode="view"
title="Actividad del Usuario"
subtitle={selectedMemberActivity?.user_id ? `ID: ${selectedMemberActivity.user_id}` : ''}
sections={[
{
title: 'Información Básica',
icon: Activity,
fields: [
{
label: 'Estado de Cuenta',
value: selectedMemberActivity?.is_active ? 'Activa' : 'Inactiva',
type: 'text',
span: 2
},
{
label: 'Estado de Verificación',
value: selectedMemberActivity?.is_verified ? 'Verificada' : 'No verificada',
type: 'text',
span: 2
},
{
label: 'Fecha de Creación',
value: selectedMemberActivity?.account_created ? new Date(selectedMemberActivity.account_created).toLocaleDateString('es-ES') : 'N/A',
type: 'text',
span: 2
}
]
},
{
title: 'Actividad Reciente',
icon: Activity,
fields: [
{
label: 'Último Inicio de Sesión',
value: selectedMemberActivity?.last_login ? new Date(selectedMemberActivity.last_login).toLocaleString('es-ES') : 'Nunca',
type: 'text',
span: 2
},
{
label: 'Última Actividad',
value: selectedMemberActivity?.last_activity ? new Date(selectedMemberActivity.last_activity).toLocaleString('es-ES') : 'N/A',
type: 'text',
span: 2
},
{
label: 'Sesiones Activas',
value: selectedMemberActivity?.active_sessions || 0,
type: 'number',
span: 2
}
]
}
]}
size="lg"
/>
</div>
);
);
};
export default TeamPage;
export default TeamPage;