Improve the frontend 2
This commit is contained in:
@@ -177,21 +177,13 @@ const DashboardPage: React.FC = () => {
|
||||
navigate('/app/operations/procurement');
|
||||
};
|
||||
|
||||
// Build stats from real API data
|
||||
// Build stats from real API data (Sales analytics removed - Professional/Enterprise tier only)
|
||||
const criticalStats = React.useMemo(() => {
|
||||
if (!dashboardStats) {
|
||||
// Return loading/empty state
|
||||
return [];
|
||||
}
|
||||
|
||||
// Format currency values
|
||||
const formatCurrency = (value: number): string => {
|
||||
return `${dashboardStats.salesCurrency}${value.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}`;
|
||||
};
|
||||
|
||||
// Determine trend direction
|
||||
const getTrendDirection = (value: number): 'up' | 'down' | 'neutral' => {
|
||||
if (value > 0) return 'up';
|
||||
@@ -199,33 +191,7 @@ const DashboardPage: React.FC = () => {
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
// Build subtitle for sales
|
||||
const salesChange = dashboardStats.salesToday * (dashboardStats.salesTrend / 100);
|
||||
const salesSubtitle = salesChange > 0
|
||||
? `+${formatCurrency(salesChange)} ${t('dashboard:messages.more_than_yesterday', 'more than yesterday')}`
|
||||
: salesChange < 0
|
||||
? `${formatCurrency(Math.abs(salesChange))} ${t('dashboard:messages.less_than_yesterday', 'less than yesterday')}`
|
||||
: t('dashboard:messages.same_as_yesterday', 'Same as yesterday');
|
||||
|
||||
// Build subtitle for products
|
||||
const productsChange = Math.round(dashboardStats.productsSoldToday * (dashboardStats.productsSoldTrend / 100));
|
||||
const productsSubtitle = productsChange !== 0
|
||||
? `${productsChange > 0 ? '+' : ''}${productsChange} ${t('dashboard:messages.more_units', 'units')}`
|
||||
: t('dashboard:messages.same_as_yesterday', 'Same as yesterday');
|
||||
|
||||
return [
|
||||
{
|
||||
title: t('dashboard:stats.sales_today', 'Sales Today'),
|
||||
value: formatCurrency(dashboardStats.salesToday),
|
||||
icon: Euro,
|
||||
variant: 'success' as const,
|
||||
trend: {
|
||||
value: Math.abs(dashboardStats.salesTrend),
|
||||
direction: getTrendDirection(dashboardStats.salesTrend),
|
||||
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||
},
|
||||
subtitle: salesSubtitle
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.pending_orders', 'Pending Orders'),
|
||||
value: dashboardStats.pendingOrders.toString(),
|
||||
@@ -240,18 +206,6 @@ const DashboardPage: React.FC = () => {
|
||||
? t('dashboard:messages.require_attention', 'Require attention')
|
||||
: t('dashboard:messages.all_caught_up', 'All caught up!')
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.products_sold', 'Products Sold'),
|
||||
value: dashboardStats.productsSoldToday.toString(),
|
||||
icon: Package,
|
||||
variant: 'info' as const,
|
||||
trend: dashboardStats.productsSoldTrend !== 0 ? {
|
||||
value: Math.abs(dashboardStats.productsSoldTrend),
|
||||
direction: getTrendDirection(dashboardStats.productsSoldTrend),
|
||||
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||
} : undefined,
|
||||
subtitle: productsSubtitle
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.stock_alerts', 'Critical Stock'),
|
||||
value: dashboardStats.criticalStock.toString(),
|
||||
@@ -406,8 +360,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-3 xl:grid-cols-6 gap-4 mb-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<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
|
||||
key={i}
|
||||
className="h-32 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg animate-pulse"
|
||||
@@ -423,7 +377,7 @@ const DashboardPage: React.FC = () => {
|
||||
) : (
|
||||
<StatsGrid
|
||||
stats={criticalStats}
|
||||
columns={6}
|
||||
columns={4}
|
||||
gap="lg"
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useModelPerformance,
|
||||
useTenantTrainingStatistics
|
||||
} from '../../../../api/hooks/training';
|
||||
import { ModelDetailsModal } from '../../../../components/domain/forecasting';
|
||||
import { ModelDetailsModal, RetrainModelModal } from '../../../../components/domain/forecasting';
|
||||
import type { IngredientResponse } from '../../../../api/types/inventory';
|
||||
import type { TrainedModelResponse, SingleProductTrainingRequest } from '../../../../api/types/training';
|
||||
|
||||
@@ -47,6 +47,7 @@ const ModelsConfigPage: React.FC = () => {
|
||||
const [selectedIngredient, setSelectedIngredient] = useState<IngredientResponse | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<TrainedModelResponse | null>(null);
|
||||
const [showTrainingModal, setShowTrainingModal] = useState(false);
|
||||
const [showRetrainModal, setShowRetrainModal] = useState(false);
|
||||
const [showModelDetailsModal, setShowModelDetailsModal] = useState(false);
|
||||
const [trainingSettings, setTrainingSettings] = useState<Partial<SingleProductTrainingRequest>>({
|
||||
seasonality_mode: 'additive',
|
||||
@@ -183,9 +184,38 @@ const ModelsConfigPage: React.FC = () => {
|
||||
setShowTrainingModal(true);
|
||||
};
|
||||
|
||||
|
||||
const handleStartRetraining = (ingredient: IngredientResponse) => {
|
||||
setSelectedIngredient(ingredient);
|
||||
|
||||
// Find and set the model for this ingredient
|
||||
const model = modelStatuses.find(status => status.ingredient.id === ingredient.id)?.model;
|
||||
if (model) {
|
||||
setSelectedModel(model);
|
||||
}
|
||||
|
||||
setShowRetrainModal(true);
|
||||
};
|
||||
|
||||
const handleRetrain = async (settings: SingleProductTrainingRequest) => {
|
||||
if (!selectedIngredient) return;
|
||||
|
||||
try {
|
||||
await trainMutation.mutateAsync({
|
||||
tenantId,
|
||||
inventoryProductId: selectedIngredient.id,
|
||||
request: settings
|
||||
});
|
||||
|
||||
addToast(`Reentrenamiento iniciado para ${selectedIngredient.name}`, { type: 'success' });
|
||||
setShowRetrainModal(false);
|
||||
setSelectedIngredient(null);
|
||||
setSelectedModel(null);
|
||||
} catch (error) {
|
||||
addToast('Error al reentrenar el modelo', { type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
if (ingredientsLoading || modelsLoading) {
|
||||
return (
|
||||
@@ -238,7 +268,7 @@ const ModelsConfigPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Precisión Promedio',
|
||||
value: statsError ? 'N/A' : (statistics?.average_accuracy ? `${Number(statistics.average_accuracy).toFixed(1)}%` : 'N/A'),
|
||||
value: statsError ? 'N/A' : (statistics?.models?.average_accuracy !== undefined && statistics?.models?.average_accuracy !== null ? `${Number(statistics.models.average_accuracy).toFixed(1)}%` : 'N/A'),
|
||||
icon: TrendingUp,
|
||||
variant: 'success',
|
||||
},
|
||||
@@ -354,7 +384,7 @@ const ModelsConfigPage: React.FC = () => {
|
||||
...(status.hasModel ? [{
|
||||
label: 'Reentrenar',
|
||||
icon: RotateCcw,
|
||||
onClick: () => handleStartTraining(status.ingredient),
|
||||
onClick: () => handleStartRetraining(status.ingredient),
|
||||
priority: 'secondary' as const
|
||||
}] : [])
|
||||
]}
|
||||
@@ -451,6 +481,22 @@ const ModelsConfigPage: React.FC = () => {
|
||||
model={selectedModel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Retrain Model Modal */}
|
||||
{selectedIngredient && (
|
||||
<RetrainModelModal
|
||||
isOpen={showRetrainModal}
|
||||
onClose={() => {
|
||||
setShowRetrainModal(false);
|
||||
setSelectedIngredient(null);
|
||||
setSelectedModel(null);
|
||||
}}
|
||||
ingredient={selectedIngredient}
|
||||
currentModel={selectedModel}
|
||||
onRetrain={handleRetrain}
|
||||
isLoading={trainMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
Calendar,
|
||||
Download,
|
||||
FileText,
|
||||
Info,
|
||||
HelpCircle
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { StatsGrid, Button, Card, Tooltip } from '../../../../components/ui';
|
||||
import { StatsGrid, Button, Card } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { useSustainabilityMetrics } from '../../../../api/hooks/sustainability';
|
||||
@@ -146,6 +145,76 @@ const SustainabilityPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we have insufficient data
|
||||
if (metrics.data_sufficient === false) {
|
||||
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-8">
|
||||
<div className="text-center py-12 max-w-2xl mx-auto">
|
||||
<div className="mb-6 inline-flex items-center justify-center w-20 h-20 bg-blue-500/10 rounded-full">
|
||||
<Info className="w-10 h-10 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('sustainability:insufficient_data.title', 'Collecting Sustainability Data')}
|
||||
</h3>
|
||||
<p className="text-base text-[var(--text-secondary)] mb-6">
|
||||
{t('sustainability:insufficient_data.description',
|
||||
'Start producing batches to see your sustainability metrics and SDG compliance status.'
|
||||
)}
|
||||
</p>
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 mb-6">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('sustainability:insufficient_data.requirements_title', 'Minimum Requirements')}
|
||||
</h4>
|
||||
<ul className="text-sm text-[var(--text-secondary)] space-y-2 text-left max-w-md mx-auto">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<span>
|
||||
{t('sustainability:insufficient_data.req_production',
|
||||
'At least 50kg of production over the analysis period'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<span>
|
||||
{t('sustainability:insufficient_data.req_baseline',
|
||||
'90 days of production history for accurate baseline calculation'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<span>
|
||||
{t('sustainability:insufficient_data.req_tracking',
|
||||
'Production batches with waste tracking enabled'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-[var(--text-tertiary)]">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>
|
||||
{t('sustainability:insufficient_data.current_production',
|
||||
'Current production: {{production}}kg of {{required}}kg minimum',
|
||||
{
|
||||
production: metrics.current_production_kg?.toFixed(1) || '0.0',
|
||||
required: metrics.minimum_production_required_kg || 50
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
{/* Page Header */}
|
||||
@@ -180,14 +249,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.waste_analytics', 'Análisis de Residuos')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.waste_subtitle', 'Desglose de residuos por tipo')}
|
||||
</p>
|
||||
@@ -254,14 +318,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.environmental_impact', 'Impacto Ambiental')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.environmental_subtitle', 'Métricas de huella ambiental')}
|
||||
</p>
|
||||
@@ -334,14 +393,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.sdg_compliance', 'Cumplimiento SDG 12.3')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.sdg_subtitle', 'Progreso hacia objetivo ONU')}
|
||||
</p>
|
||||
@@ -413,14 +467,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.grant_readiness', 'Subvenciones Disponibles')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.grant_subtitle', 'Programas de financiación elegibles')}
|
||||
</p>
|
||||
@@ -508,14 +557,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.financial_impact', 'Impacto Financiero')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.financial_subtitle', 'Costes y ahorros de sostenibilidad')}
|
||||
</p>
|
||||
|
||||
@@ -8,19 +8,26 @@ import { PageHeader } from '../../../../components/layout';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Equipment } from '../../../../api/types/equipment';
|
||||
import { EquipmentModal } from '../../../../components/domain/equipment/EquipmentModal';
|
||||
import { useEquipment, useCreateEquipment, useUpdateEquipment } from '../../../../api/hooks/equipment';
|
||||
import { DeleteEquipmentModal } from '../../../../components/domain/equipment/DeleteEquipmentModal';
|
||||
import { MaintenanceHistoryModal } from '../../../../components/domain/equipment/MaintenanceHistoryModal';
|
||||
import { ScheduleMaintenanceModal, type MaintenanceScheduleData } from '../../../../components/domain/equipment/ScheduleMaintenanceModal';
|
||||
import { useEquipment, useCreateEquipment, useUpdateEquipment, useDeleteEquipment, useHardDeleteEquipment } from '../../../../api/hooks/equipment';
|
||||
|
||||
const MaquinariaPage: React.FC = () => {
|
||||
const { t } = useTranslation(['equipment', 'common']);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [selectedItem, setSelectedItem] = useState<Equipment | null>(null);
|
||||
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
||||
const [showEquipmentModal, setShowEquipmentModal] = useState(false);
|
||||
const [equipmentModalMode, setEquipmentModalMode] = useState<'view' | 'edit' | 'create'>('create');
|
||||
const [selectedEquipment, setSelectedEquipment] = useState<Equipment | null>(null);
|
||||
|
||||
// New modal states
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
||||
const [equipmentForAction, setEquipmentForAction] = useState<Equipment | null>(null);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
@@ -29,9 +36,11 @@ const MaquinariaPage: React.FC = () => {
|
||||
is_active: true
|
||||
});
|
||||
|
||||
// Mutations for create and update
|
||||
// Mutations for create, update, and delete
|
||||
const createEquipmentMutation = useCreateEquipment(tenantId);
|
||||
const updateEquipmentMutation = useUpdateEquipment(tenantId);
|
||||
const deleteEquipmentMutation = useDeleteEquipment(tenantId);
|
||||
const hardDeleteEquipmentMutation = useHardDeleteEquipment(tenantId);
|
||||
|
||||
const handleCreateEquipment = () => {
|
||||
setSelectedEquipment({
|
||||
@@ -73,19 +82,58 @@ const MaquinariaPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleMaintenance = (equipmentId: string) => {
|
||||
console.log('Schedule maintenance for equipment:', equipmentId);
|
||||
// Implementation would go here
|
||||
const handleScheduleMaintenance = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowScheduleModal(true);
|
||||
};
|
||||
|
||||
const handleAcknowledgeAlert = (equipmentId: string, alertId: string) => {
|
||||
console.log('Acknowledge alert:', alertId, 'for equipment:', equipmentId);
|
||||
// Implementation would go here
|
||||
const handleScheduleMaintenanceSubmit = async (equipmentId: string, maintenanceData: MaintenanceScheduleData) => {
|
||||
try {
|
||||
// Update next maintenance date based on scheduled date
|
||||
await updateEquipmentMutation.mutateAsync({
|
||||
equipmentId: equipmentId,
|
||||
equipmentData: {
|
||||
nextMaintenance: maintenanceData.scheduledDate
|
||||
} as Partial<Equipment>
|
||||
});
|
||||
setShowScheduleModal(false);
|
||||
setEquipmentForAction(null);
|
||||
} catch (error) {
|
||||
console.error('Error scheduling maintenance:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewMaintenanceHistory = (equipmentId: string) => {
|
||||
console.log('View maintenance history for equipment:', equipmentId);
|
||||
// Implementation would go here
|
||||
const handleViewMaintenanceHistory = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowHistoryModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteEquipment = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleSoftDelete = async (equipmentId: string) => {
|
||||
try {
|
||||
await deleteEquipmentMutation.mutateAsync(equipmentId);
|
||||
setShowDeleteModal(false);
|
||||
setEquipmentForAction(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting equipment:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleHardDelete = async (equipmentId: string) => {
|
||||
try {
|
||||
await hardDeleteEquipmentMutation.mutateAsync(equipmentId);
|
||||
setShowDeleteModal(false);
|
||||
setEquipmentForAction(null);
|
||||
} catch (error) {
|
||||
console.error('Error hard deleting equipment:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEquipment = async (equipmentData: Equipment) => {
|
||||
@@ -200,13 +248,9 @@ const MaquinariaPage: React.FC = () => {
|
||||
];
|
||||
|
||||
const handleShowMaintenanceDetails = (equipment: Equipment) => {
|
||||
setSelectedItem(equipment);
|
||||
setShowMaintenanceModal(true);
|
||||
};
|
||||
|
||||
const handleCloseMaintenanceModal = () => {
|
||||
setShowMaintenanceModal(false);
|
||||
setSelectedItem(null);
|
||||
setSelectedEquipment(equipment);
|
||||
setEquipmentModalMode('view');
|
||||
setShowEquipmentModal(true);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
@@ -336,23 +380,25 @@ const MaquinariaPage: React.FC = () => {
|
||||
priority: 'primary',
|
||||
onClick: () => handleShowMaintenanceDetails(equipment)
|
||||
},
|
||||
{
|
||||
label: t('actions.edit'),
|
||||
icon: Edit,
|
||||
priority: 'secondary',
|
||||
onClick: () => handleEditEquipment(equipment.id)
|
||||
},
|
||||
{
|
||||
label: t('actions.view_history'),
|
||||
icon: History,
|
||||
priority: 'secondary',
|
||||
onClick: () => handleViewMaintenanceHistory(equipment.id)
|
||||
onClick: () => handleViewMaintenanceHistory(equipment)
|
||||
},
|
||||
{
|
||||
label: t('actions.schedule_maintenance'),
|
||||
icon: Wrench,
|
||||
priority: 'secondary',
|
||||
onClick: () => handleScheduleMaintenance(equipment.id)
|
||||
highlighted: true,
|
||||
onClick: () => handleScheduleMaintenance(equipment)
|
||||
},
|
||||
{
|
||||
label: t('actions.delete'),
|
||||
icon: Trash2,
|
||||
priority: 'secondary',
|
||||
destructive: true,
|
||||
onClick: () => handleDeleteEquipment(equipment)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -372,183 +418,7 @@ const MaquinariaPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Maintenance Details Modal */}
|
||||
{selectedItem && showMaintenanceModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto my-8">
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-[var(--text-primary)]">
|
||||
{selectedItem.name}
|
||||
</h2>
|
||||
<p className="text-[var(--text-secondary)] text-sm">
|
||||
{selectedItem.model} - {selectedItem.serialNumber}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseMaintenanceModal}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Equipment Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-2 text-sm sm:text-base">{t('fields.status')}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-2 h-2 sm:w-3 sm:h-3 rounded-full"
|
||||
style={{ backgroundColor: getStatusConfig(selectedItem.status).color }}
|
||||
/>
|
||||
<span className="text-[var(--text-primary)] text-sm sm:text-base">
|
||||
{t(`equipment_status.${selectedItem.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-2 text-sm sm:text-base">{t('fields.efficiency')}</h3>
|
||||
<div className="text-lg sm:text-2xl font-bold text-[var(--text-primary)]">
|
||||
{selectedItem.efficiency}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Information */}
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-3 sm:mb-4 text-sm sm:text-base">{t('maintenance.title')}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">{t('maintenance.last')}</p>
|
||||
<p className="text-[var(--text-primary)] text-sm sm:text-base">
|
||||
{new Date(selectedItem.lastMaintenance).toLocaleDateString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">{t('maintenance.next')}</p>
|
||||
<p className={`font-medium ${(new Date(selectedItem.nextMaintenance).getTime() < new Date().getTime()) ? 'text-red-500' : 'text-[var(--text-primary)]'} text-sm sm:text-base`}>
|
||||
{new Date(selectedItem.nextMaintenance).toLocaleDateString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">{t('maintenance.interval')}</p>
|
||||
<p className="text-[var(--text-primary)] text-sm sm:text-base">
|
||||
{selectedItem.maintenanceInterval} {t('common:units.days')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{new Date(selectedItem.nextMaintenance).getTime() < new Date().getTime() && (
|
||||
<div className="mt-3 p-2 bg-red-50 dark:bg-red-900/20 rounded border-l-2 border-red-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-xs sm:text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{t('maintenance.overdue')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Alerts */}
|
||||
{selectedItem.alerts.filter(a => !a.acknowledged).length > 0 && (
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-3 sm:mb-4 text-sm sm:text-base">{t('alerts.title')}</h3>
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{selectedItem.alerts.filter(a => !a.acknowledged).map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`p-2 sm:p-3 rounded border-l-2 ${
|
||||
alert.type === 'critical' ? 'bg-red-50 border-red-500 dark:bg-red-900/20' :
|
||||
alert.type === 'warning' ? 'bg-orange-50 border-orange-500 dark:bg-orange-900/20' :
|
||||
'bg-blue-50 border-blue-500 dark:bg-blue-900/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className={`w-3 h-3 sm:w-4 sm:h-4 ${
|
||||
alert.type === 'critical' ? 'text-red-500' :
|
||||
alert.type === 'warning' ? 'text-orange-500' : 'text-blue-500'
|
||||
}`} />
|
||||
<span className="font-medium text-[var(--text-primary)] text-xs sm:text-sm">
|
||||
{alert.message}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)] hidden sm:block">
|
||||
{new Date(alert.timestamp).toLocaleString('es-ES')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maintenance History */}
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-3 sm:mb-4 text-sm sm:text-base">{t('maintenance.history')}</h3>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{selectedItem.maintenanceHistory.map((history) => (
|
||||
<div key={history.id} className="border-b border-[var(--border-primary)] pb-2 sm:pb-3 last:border-0 last:pb-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)] text-sm">{history.description}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{new Date(history.date).toLocaleDateString('es-ES')} - {history.technician}
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-1.5 py-0.5 sm:px-2 sm:py-1 bg-[var(--bg-tertiary)] text-xs rounded">
|
||||
{t(`maintenance.type.${history.type}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 sm:mt-2 flex flex-wrap gap-2">
|
||||
<span className="text-xs">
|
||||
<span className="text-[var(--text-secondary)]">{t('common:actions.cost')}:</span>
|
||||
<span className="font-medium text-[var(--text-primary)]"> €{history.cost}</span>
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
<span className="text-[var(--text-secondary)]">{t('fields.uptime')}:</span>
|
||||
<span className="font-medium text-[var(--text-primary)]"> {history.downtime}h</span>
|
||||
</span>
|
||||
</div>
|
||||
{history.partsUsed.length > 0 && (
|
||||
<div className="mt-1 sm:mt-2">
|
||||
<span className="text-xs text-[var(--text-secondary)]">{t('fields.parts')}:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{history.partsUsed.map((part, index) => (
|
||||
<span key={index} className="px-1.5 py-0.5 sm:px-2 sm:py-1 bg-[var(--bg-tertiary)] text-xs rounded">
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 sm:space-x-3 mt-4 sm:mt-6">
|
||||
<Button variant="outline" size="sm" onClick={handleCloseMaintenanceModal}>
|
||||
{t('common:actions.close')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => selectedItem && handleScheduleMaintenance(selectedItem.id)}>
|
||||
{t('actions.schedule_maintenance')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Equipment Modal */}
|
||||
{/* Equipment Modal - Used for View Details, Edit, and Create */}
|
||||
{showEquipmentModal && (
|
||||
<EquipmentModal
|
||||
isOpen={showEquipmentModal}
|
||||
@@ -561,6 +431,47 @@ const MaquinariaPage: React.FC = () => {
|
||||
mode={equipmentModalMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Equipment Modal */}
|
||||
{showDeleteModal && equipmentForAction && (
|
||||
<DeleteEquipmentModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setEquipmentForAction(null);
|
||||
}}
|
||||
equipment={equipmentForAction}
|
||||
onSoftDelete={handleSoftDelete}
|
||||
onHardDelete={handleHardDelete}
|
||||
isLoading={deleteEquipmentMutation.isPending || hardDeleteEquipmentMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Maintenance History Modal */}
|
||||
{showHistoryModal && equipmentForAction && (
|
||||
<MaintenanceHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => {
|
||||
setShowHistoryModal(false);
|
||||
setEquipmentForAction(null);
|
||||
}}
|
||||
equipment={equipmentForAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Schedule Maintenance Modal */}
|
||||
{showScheduleModal && equipmentForAction && (
|
||||
<ScheduleMaintenanceModal
|
||||
isOpen={showScheduleModal}
|
||||
onClose={() => {
|
||||
setShowScheduleModal(false);
|
||||
setEquipmentForAction(null);
|
||||
}}
|
||||
equipment={equipmentForAction}
|
||||
onSchedule={handleScheduleMaintenanceSubmit}
|
||||
isLoading={updateEquipmentMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ const ProcurementPage: React.FC = () => {
|
||||
|
||||
const handleTriggerScheduler = async () => {
|
||||
try {
|
||||
await triggerSchedulerMutation.mutateAsync({ tenantId });
|
||||
await triggerSchedulerMutation.mutateAsync(tenantId);
|
||||
toast.success('Scheduler ejecutado exitosamente');
|
||||
refetchPOs();
|
||||
} catch (error) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
export { default as ProcurementPage } from './ProcurementPage';
|
||||
export { default as ProcurementPage } from './ProcurementPage';
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
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 { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader, Trash2, DollarSign, Package } from 'lucide-react';
|
||||
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, AddModal, SearchAndFilter, DialogModal, Modal, ModalHeader, ModalBody, 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, useCreateSupplier, useUpdateSupplier, useApproveSupplier, useDeleteSupplier, useHardDeleteSupplier } from '../../../../api/hooks/suppliers';
|
||||
import { useSuppliers, useSupplierStatistics, useCreateSupplier, useUpdateSupplier, useApproveSupplier, useDeleteSupplier, useHardDeleteSupplier, useSupplierPriceLists, useCreateSupplierPriceList, useUpdateSupplierPriceList, useDeleteSupplierPriceList } 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';
|
||||
import { DeleteSupplierModal, SupplierPriceListViewModal, PriceListModal } from '../../../../components/domain/suppliers';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const SuppliersPage: React.FC = () => {
|
||||
const [activeTab] = useState('all');
|
||||
@@ -23,6 +24,9 @@ const SuppliersPage: React.FC = () => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
const [supplierToApprove, setSupplierToApprove] = useState<any>(null);
|
||||
const [showPriceListView, setShowPriceListView] = useState(false);
|
||||
const [showAddPrice, setShowAddPrice] = useState(false);
|
||||
const [priceListSupplier, setPriceListSupplier] = useState<any>(null);
|
||||
|
||||
// Get tenant ID from tenant store (preferred) or auth user (fallback)
|
||||
const currentTenant = useCurrentTenant();
|
||||
@@ -48,6 +52,7 @@ const SuppliersPage: React.FC = () => {
|
||||
|
||||
const suppliers = suppliersData || [];
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Mutation hooks
|
||||
const createSupplierMutation = useCreateSupplier();
|
||||
@@ -56,6 +61,21 @@ const SuppliersPage: React.FC = () => {
|
||||
const softDeleteMutation = useDeleteSupplier();
|
||||
const hardDeleteMutation = useHardDeleteSupplier();
|
||||
|
||||
// Price list hooks
|
||||
const {
|
||||
data: priceListsData,
|
||||
isLoading: priceListsLoading,
|
||||
isRefetching: isRefetchingPriceLists
|
||||
} = useSupplierPriceLists(
|
||||
tenantId,
|
||||
priceListSupplier?.id || '',
|
||||
!!priceListSupplier?.id && showPriceListView
|
||||
);
|
||||
|
||||
const createPriceListMutation = useCreateSupplierPriceList();
|
||||
const updatePriceListMutation = useUpdateSupplierPriceList();
|
||||
const deletePriceListMutation = useDeleteSupplierPriceList();
|
||||
|
||||
// Delete handlers
|
||||
const handleSoftDelete = async (supplierId: string) => {
|
||||
await softDeleteMutation.mutateAsync({ tenantId, supplierId });
|
||||
@@ -65,6 +85,27 @@ const SuppliersPage: React.FC = () => {
|
||||
return await hardDeleteMutation.mutateAsync({ tenantId, supplierId });
|
||||
};
|
||||
|
||||
// Price list handlers
|
||||
const handlePriceListSaveComplete = async () => {
|
||||
if (!tenantId || !priceListSupplier?.id) return;
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['supplier-price-lists', tenantId, priceListSupplier.id]
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddPriceSubmit = async (priceListData: any) => {
|
||||
if (!priceListSupplier) return;
|
||||
|
||||
await createPriceListMutation.mutateAsync({
|
||||
tenantId,
|
||||
supplierId: priceListSupplier.id,
|
||||
priceListData
|
||||
});
|
||||
|
||||
// Close the add modal
|
||||
setShowAddPrice(false);
|
||||
};
|
||||
|
||||
const getSupplierStatusConfig = (status: SupplierStatus) => {
|
||||
const statusConfig = {
|
||||
[SupplierStatus.ACTIVE]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: CheckCircle },
|
||||
@@ -274,6 +315,18 @@ const SuppliersPage: React.FC = () => {
|
||||
setShowForm(true);
|
||||
}
|
||||
},
|
||||
// Manage products action
|
||||
{
|
||||
label: t('suppliers:actions.manage_products'),
|
||||
icon: Package,
|
||||
variant: 'outline',
|
||||
priority: 'secondary',
|
||||
highlighted: true,
|
||||
onClick: () => {
|
||||
setPriceListSupplier(supplier);
|
||||
setShowPriceListView(true);
|
||||
}
|
||||
},
|
||||
// Approval action - Only show for pending suppliers + admin/super_admin
|
||||
...(supplier.status === SupplierStatus.PENDING_APPROVAL &&
|
||||
(user?.role === 'admin' || user?.role === 'super_admin')
|
||||
@@ -769,7 +822,7 @@ const SuppliersPage: React.FC = () => {
|
||||
placeholder: t('suppliers:placeholders.notes')
|
||||
}
|
||||
]
|
||||
}] : [])
|
||||
}] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -942,6 +995,55 @@ const SuppliersPage: React.FC = () => {
|
||||
}}
|
||||
loading={approveSupplierMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Price List View Modal */}
|
||||
{priceListSupplier && (
|
||||
<SupplierPriceListViewModal
|
||||
isOpen={showPriceListView}
|
||||
onClose={() => {
|
||||
setShowPriceListView(false);
|
||||
setPriceListSupplier(null);
|
||||
}}
|
||||
supplier={priceListSupplier}
|
||||
priceLists={priceListsData || []}
|
||||
loading={priceListsLoading}
|
||||
tenantId={tenantId}
|
||||
onAddPrice={() => setShowAddPrice(true)}
|
||||
onEditPrice={async (priceId, updateData) => {
|
||||
await updatePriceListMutation.mutateAsync({
|
||||
tenantId,
|
||||
supplierId: priceListSupplier.id,
|
||||
priceListId: priceId,
|
||||
priceListData: updateData
|
||||
});
|
||||
}}
|
||||
onDeletePrice={async (priceId) => {
|
||||
await deletePriceListMutation.mutateAsync({
|
||||
tenantId,
|
||||
supplierId: priceListSupplier.id,
|
||||
priceListId: priceId
|
||||
});
|
||||
}}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingPriceLists}
|
||||
onSaveComplete={handlePriceListSaveComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Price Modal */}
|
||||
{priceListSupplier && (
|
||||
<PriceListModal
|
||||
isOpen={showAddPrice}
|
||||
onClose={() => setShowAddPrice(false)}
|
||||
onSave={handleAddPriceSubmit}
|
||||
mode="create"
|
||||
loading={createPriceListMutation.isPending}
|
||||
excludeProductIds={priceListsData?.map(p => p.inventory_product_id) || []}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingPriceLists}
|
||||
onSaveComplete={handlePriceListSaveComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download, CreditCard, X, Activity, Database, Zap, HardDrive, ShoppingCart, ChefHat } from 'lucide-react';
|
||||
import { Button, Card, Badge, Modal } from '../../../../components/ui';
|
||||
import { DialogModal } from '../../../../components/ui/DialogModal/DialogModal';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
|
||||
import { useSubscriptionEvents } from '../../../../contexts/SubscriptionEventsContext';
|
||||
import { SubscriptionPricingCards } from '../../../../components/subscription/SubscriptionPricingCards';
|
||||
|
||||
const SubscriptionPage: React.FC = () => {
|
||||
const user = useAuthUser();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { addToast } = useToast();
|
||||
const { notifySubscriptionChanged } = useSubscriptionEvents();
|
||||
|
||||
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
||||
@@ -154,6 +157,9 @@ const SubscriptionPage: React.FC = () => {
|
||||
if (result.success) {
|
||||
addToast(result.message, { type: 'success' });
|
||||
|
||||
// Broadcast subscription change event to refresh sidebar and other components
|
||||
notifySubscriptionChanged();
|
||||
|
||||
await loadSubscriptionData();
|
||||
setUpgradeDialogOpen(false);
|
||||
setSelectedPlan('');
|
||||
@@ -325,7 +331,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Usuarios</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{usageSummary.usage.users.current}/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
|
||||
{usageSummary.usage.users.current}/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,7 +339,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Ubicaciones</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{usageSummary.usage.locations.current}/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
|
||||
{usageSummary.usage.locations.current}/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,7 +383,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
|
||||
@@ -398,7 +404,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
|
||||
@@ -425,7 +431,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
|
||||
@@ -446,7 +452,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.recipes.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.recipes.unlimited ? '∞' : usageSummary.usage.recipes.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.recipes.unlimited ? '∞' : usageSummary.usage.recipes.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.recipes.usage_percentage} />
|
||||
@@ -467,7 +473,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.suppliers.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.suppliers.unlimited ? '∞' : usageSummary.usage.suppliers.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.suppliers.unlimited ? '∞' : usageSummary.usage.suppliers.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.suppliers.usage_percentage} />
|
||||
@@ -494,7 +500,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.training_jobs_today.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.training_jobs_today.unlimited ? '∞' : usageSummary.usage.training_jobs_today.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.training_jobs_today.unlimited ? '∞' : usageSummary.usage.training_jobs_today.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.training_jobs_today.usage_percentage} />
|
||||
@@ -515,7 +521,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.forecasts_today.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.forecasts_today.unlimited ? '∞' : usageSummary.usage.forecasts_today.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.forecasts_today.unlimited ? '∞' : usageSummary.usage.forecasts_today.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.forecasts_today.usage_percentage} />
|
||||
@@ -542,7 +548,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.api_calls_this_hour.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.api_calls_this_hour.unlimited ? '∞' : usageSummary.usage.api_calls_this_hour.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.api_calls_this_hour.unlimited ? '∞' : usageSummary.usage.api_calls_this_hour.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.api_calls_this_hour.usage_percentage} />
|
||||
@@ -704,89 +710,61 @@ const SubscriptionPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Upgrade Modal */}
|
||||
{/* Upgrade Dialog */}
|
||||
{upgradeDialogOpen && selectedPlan && availablePlans && (
|
||||
<Modal
|
||||
<DialogModal
|
||||
isOpen={upgradeDialogOpen}
|
||||
onClose={() => setUpgradeDialogOpen(false)}
|
||||
title="Confirmar Cambio de Plan"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
¿Estás seguro de que quieres cambiar tu plan de suscripción?
|
||||
</p>
|
||||
{availablePlans.plans[selectedPlan] && usageSummary && (
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Plan actual:</span>
|
||||
<span>{usageSummary.plan}</span>
|
||||
message={
|
||||
<div className="space-y-3">
|
||||
<p>¿Estás seguro de que quieres cambiar tu plan de suscripción?</p>
|
||||
{availablePlans.plans[selectedPlan as keyof typeof availablePlans.plans] && usageSummary && (
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Plan actual:</span>
|
||||
<span>{usageSummary.plan}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Nuevo plan:</span>
|
||||
<span>{availablePlans.plans[selectedPlan as keyof typeof availablePlans.plans].name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-medium">
|
||||
<span>Nuevo precio:</span>
|
||||
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan as keyof typeof availablePlans.plans].monthly_price)}/mes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Nuevo plan:</span>
|
||||
<span>{availablePlans.plans[selectedPlan].name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-medium">
|
||||
<span>Nuevo precio:</span>
|
||||
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setUpgradeDialogOpen(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUpgradeConfirm}
|
||||
disabled={upgrading}
|
||||
className="flex-1"
|
||||
>
|
||||
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
type="confirm"
|
||||
onConfirm={handleUpgradeConfirm}
|
||||
onCancel={() => setUpgradeDialogOpen(false)}
|
||||
confirmLabel="Confirmar Cambio"
|
||||
cancelLabel="Cancelar"
|
||||
loading={upgrading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cancellation Modal */}
|
||||
{/* Cancellation Dialog */}
|
||||
{cancellationDialogOpen && (
|
||||
<Modal
|
||||
<DialogModal
|
||||
isOpen={cancellationDialogOpen}
|
||||
onClose={() => setCancellationDialogOpen(false)}
|
||||
title="Cancelar Suscripción"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
¿Estás seguro de que deseas cancelar tu suscripción? Esta acción no se puede deshacer.
|
||||
</p>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
Perderás acceso a las funcionalidades premium al final del período de facturación actual.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancellationDialogOpen(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelling}
|
||||
className="flex-1"
|
||||
>
|
||||
{cancelling ? 'Cancelando...' : 'Confirmar Cancelación'}
|
||||
</Button>
|
||||
message={
|
||||
<div className="space-y-3">
|
||||
<p>¿Estás seguro de que deseas cancelar tu suscripción? Esta acción no se puede deshacer.</p>
|
||||
<p>Perderás acceso a las funcionalidades premium al final del período de facturación actual.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
type="warning"
|
||||
onConfirm={handleCancelSubscription}
|
||||
onCancel={() => setCancellationDialogOpen(false)}
|
||||
confirmLabel="Confirmar Cancelación"
|
||||
cancelLabel="Volver"
|
||||
loading={cancelling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user