Improve the frontend modals
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user