Add new API in the frontend

This commit is contained in:
Urtzi Alfaro
2025-09-11 18:21:32 +02:00
parent 523b926854
commit 55f31a3630
16 changed files with 2719 additions and 1806 deletions

View File

@@ -1,28 +1,68 @@
import React, { useState } from 'react';
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings } from 'lucide-react';
import React, { useState, useMemo } from 'react';
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader } from 'lucide-react';
import { Button, Card, Badge, Select, Table } from '../../../../components/ui';
import type { TableColumn } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { DemandChart, ForecastTable, SeasonalityIndicator, AlertsPanel } from '../../../../components/domain/forecasting';
import { useTenantForecasts, useForecastStatistics } from '../../../../api/hooks/forecasting';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useAuthUser } from '../../../../stores/auth.store';
import { ForecastResponse } from '../../../../api/types/forecasting';
const ForecastingPage: React.FC = () => {
const [selectedProduct, setSelectedProduct] = useState('all');
const [forecastPeriod, setForecastPeriod] = useState('7');
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
const forecastData = {
accuracy: 92,
totalDemand: 1247,
growthTrend: 8.5,
seasonalityFactor: 1.15,
};
// Get tenant ID from auth user
const user = useAuthUser();
const tenantId = user?.tenant_id || '';
const products = [
{ id: 'all', name: 'Todos los productos' },
{ id: 'bread', name: 'Panes' },
{ id: 'pastry', name: 'Bollería' },
{ id: 'cake', name: 'Tartas' },
];
// Calculate date range based on selected period
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - parseInt(forecastPeriod));
// API hooks
const {
data: forecastsData,
isLoading: forecastsLoading,
error: forecastsError
} = useTenantForecasts(tenantId, {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
...(selectedProduct !== 'all' && { inventory_product_id: selectedProduct }),
limit: 100
});
const {
data: statisticsData,
isLoading: statisticsLoading,
error: statisticsError
} = useForecastStatistics(tenantId);
// Fetch real inventory data
const {
data: ingredientsData,
isLoading: ingredientsLoading,
error: ingredientsError
} = useIngredients(tenantId);
// Build products list from real inventory data
const products = useMemo(() => {
const productList = [{ id: 'all', name: 'Todos los productos' }];
if (ingredientsData && ingredientsData.length > 0) {
const inventoryProducts = ingredientsData.map(ingredient => ({
id: ingredient.id,
name: ingredient.name,
category: ingredient.category,
}));
productList.push(...inventoryProducts);
}
return productList;
}, [ingredientsData]);
const periods = [
{ value: '7', label: '7 días' },
@@ -31,78 +71,51 @@ const ForecastingPage: React.FC = () => {
{ value: '90', label: '3 meses' },
];
const mockForecasts = [
{
id: '1',
product: 'Pan de Molde Integral',
currentStock: 25,
forecastDemand: 45,
recommendedProduction: 50,
confidence: 95,
trend: 'up',
stockoutRisk: 'low',
},
{
id: '2',
product: 'Croissants de Mantequilla',
currentStock: 18,
forecastDemand: 32,
recommendedProduction: 35,
confidence: 88,
trend: 'stable',
stockoutRisk: 'medium',
},
{
id: '3',
product: 'Baguettes Francesas',
currentStock: 12,
forecastDemand: 28,
recommendedProduction: 30,
confidence: 91,
trend: 'down',
stockoutRisk: 'high',
},
];
const alerts = [
{
id: '1',
type: 'stockout',
product: 'Baguettes Francesas',
message: 'Alto riesgo de agotamiento en las próximas 24h',
severity: 'high',
recommendation: 'Incrementar producción en 15 unidades',
},
{
id: '2',
type: 'overstock',
product: 'Magdalenas',
message: 'Probable exceso de stock para mañana',
severity: 'medium',
recommendation: 'Reducir producción en 20%',
},
{
id: '3',
type: 'weather',
product: 'Todos',
message: 'Lluvia prevista - incremento esperado en demanda de bollería',
severity: 'info',
recommendation: 'Aumentar producción de productos de interior en 10%',
},
];
const weatherImpact = {
today: 'sunny',
temperature: 22,
demandFactor: 0.95,
affectedCategories: ['helados', 'bebidas frías'],
// Transform forecast data for table display
const transformForecastsForTable = (forecasts: ForecastResponse[]) => {
return forecasts.map(forecast => ({
id: forecast.id,
product: forecast.inventory_product_id, // Will need to map to product name
currentStock: 'N/A', // Not available in forecast data
forecastDemand: forecast.predicted_demand,
recommendedProduction: Math.ceil(forecast.predicted_demand * 1.1), // Simple calculation
confidence: Math.round(forecast.confidence_level * 100),
trend: forecast.predicted_demand > 0 ? 'up' : 'stable',
stockoutRisk: forecast.confidence_level > 0.8 ? 'low' : forecast.confidence_level > 0.6 ? 'medium' : 'high',
}));
};
const seasonalInsights = [
{ period: 'Mañana', factor: 1.2, products: ['Pan', 'Bollería'] },
{ period: 'Tarde', factor: 0.8, products: ['Tartas', 'Dulces'] },
{ period: 'Fin de semana', factor: 1.4, products: ['Tartas especiales'] },
];
// Generate alerts based on forecast data
const generateAlertsFromForecasts = (forecasts: ForecastResponse[]) => {
return forecasts
.filter(forecast => forecast.confidence_level < 0.7 || forecast.predicted_demand > 50)
.slice(0, 3) // Limit to 3 alerts
.map((forecast, index) => ({
id: (index + 1).toString(),
type: forecast.confidence_level < 0.7 ? 'low-confidence' : 'high-demand',
product: forecast.inventory_product_id,
message: forecast.confidence_level < 0.7
? `Baja confianza en predicción (${Math.round(forecast.confidence_level * 100)}%)`
: `Alta demanda prevista: ${forecast.predicted_demand} unidades`,
severity: forecast.confidence_level < 0.5 ? 'high' : 'medium',
recommendation: forecast.confidence_level < 0.7
? 'Revisar datos históricos y factores externos'
: `Considerar aumentar producción a ${Math.ceil(forecast.predicted_demand * 1.2)} unidades`
}));
};
// Extract weather data from first forecast (if available)
const getWeatherImpact = (forecasts: ForecastResponse[]) => {
const firstForecast = forecasts?.[0];
if (!firstForecast) return null;
return {
today: firstForecast.weather_description || 'N/A',
temperature: firstForecast.weather_temperature || 0,
demandFactor: 1.0, // Could be calculated based on weather
affectedCategories: [], // Could be derived from business logic
};
};
const getTrendIcon = (trend: string) => {
switch (trend) {
@@ -177,6 +190,20 @@ const ForecastingPage: React.FC = () => {
},
];
// Derived data from API responses
const forecasts = forecastsData?.forecasts || [];
const transformedForecasts = transformForecastsForTable(forecasts);
const alerts = generateAlertsFromForecasts(forecasts);
const weatherImpact = getWeatherImpact(forecasts);
const isLoading = forecastsLoading || statisticsLoading || ingredientsLoading;
const hasError = forecastsError || statisticsError || ingredientsError;
// Calculate metrics from real data
const totalDemand = forecasts.reduce((sum, f) => sum + f.predicted_demand, 0);
const averageConfidence = forecasts.length > 0
? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100)
: 0;
return (
<div className="p-6 space-y-6">
<PageHeader
@@ -196,57 +223,87 @@ const ForecastingPage: React.FC = () => {
}
/>
{/* Key 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)]">Precisión del Modelo</p>
<p className="text-3xl font-bold text-[var(--color-success)]">{forecastData.accuracy}%</p>
</div>
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
<BarChart3 className="h-6 w-6 text-[var(--color-success)]" />
</div>
</div>
{isLoading && (
<Card className="p-6 flex items-center justify-center">
<Loader className="h-6 w-6 animate-spin mr-2" />
<span>Cargando predicciones...</span>
</Card>
)}
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Demanda Prevista</p>
<p className="text-3xl font-bold text-[var(--color-info)]">{forecastData.totalDemand}</p>
<p className="text-xs text-[var(--text-tertiary)]">próximos {forecastPeriod} días</p>
</div>
<Calendar className="h-12 w-12 text-[var(--color-info)]" />
{hasError && (
<Card className="p-6 bg-red-50 border-red-200">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-red-600 mr-2" />
<span className="text-red-800">Error al cargar las predicciones. Por favor, inténtalo de nuevo.</span>
</div>
</Card>
)}
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Tendencia</p>
<p className="text-3xl font-bold text-purple-600">+{forecastData.growthTrend}%</p>
<p className="text-xs text-[var(--text-tertiary)]">vs período anterior</p>
</div>
<TrendingUp className="h-12 w-12 text-purple-600" />
</div>
</Card>
{!isLoading && !hasError && (
<>
{/* Key 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)]">Precisión del Modelo</p>
<p className="text-3xl font-bold text-[var(--color-success)]">
{statisticsData?.accuracy_metrics?.average_accuracy
? Math.round(statisticsData.accuracy_metrics.average_accuracy * 100)
: averageConfidence}%
</p>
</div>
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
<BarChart3 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)]">Factor Estacional</p>
<p className="text-3xl font-bold text-[var(--color-primary)]">{forecastData.seasonalityFactor}x</p>
<p className="text-xs text-[var(--text-tertiary)]">multiplicador actual</p>
</div>
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<svg className="h-6 w-6 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" />
</svg>
</div>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Demanda Prevista</p>
<p className="text-3xl font-bold text-[var(--color-info)]">{Math.round(totalDemand)}</p>
<p className="text-xs text-[var(--text-tertiary)]">próximos {forecastPeriod} días</p>
</div>
<Calendar className="h-12 w-12 text-[var(--color-info)]" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Tendencia</p>
<p className="text-3xl font-bold text-purple-600">
+{statisticsData?.accuracy_metrics?.accuracy_trend
? Math.round(statisticsData.accuracy_metrics.accuracy_trend * 100)
: 5}%
</p>
<p className="text-xs text-[var(--text-tertiary)]">vs período anterior</p>
</div>
<TrendingUp className="h-12 w-12 text-purple-600" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Predicciones</p>
<p className="text-3xl font-bold text-[var(--color-primary)]">
{statisticsData?.total_forecasts || forecasts.length}
</p>
<p className="text-xs text-[var(--text-tertiary)]">generadas</p>
</div>
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<svg className="h-6 w-6 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" />
</svg>
</div>
</div>
</Card>
</div>
</Card>
</div>
</>
)}
{/* Controls */}
<Card className="p-6">
@@ -308,7 +365,7 @@ const ForecastingPage: React.FC = () => {
period={forecastPeriod}
/>
) : (
<ForecastTable forecasts={mockForecasts} />
<ForecastTable forecasts={transformedForecasts} />
)}
</div>
@@ -317,67 +374,92 @@ const ForecastingPage: React.FC = () => {
<AlertsPanel alerts={alerts} />
{/* Weather Impact */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Impacto Meteorológico</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Hoy:</span>
<div className="flex items-center">
<span className="text-sm font-medium">{weatherImpact.temperature}°C</span>
<div className="ml-2 w-6 h-6 bg-yellow-400 rounded-full"></div>
{weatherImpact && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Impacto Meteorológico</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Hoy:</span>
<div className="flex items-center">
<span className="text-sm font-medium">{weatherImpact.temperature}°C</span>
<div className="ml-2 w-6 h-6 bg-yellow-400 rounded-full"></div>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Factor de demanda:</span>
<span className="text-sm font-medium text-[var(--color-info)]">{weatherImpact.demandFactor}x</span>
</div>
<div className="mt-4">
<p className="text-xs text-[var(--text-tertiary)] mb-2">Categorías afectadas:</p>
<div className="flex flex-wrap gap-1">
{weatherImpact.affectedCategories.map((category, index) => (
<Badge key={index} variant="blue">{category}</Badge>
))}
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Condiciones:</span>
<span className="text-sm font-medium text-[var(--color-info)]">{weatherImpact.today}</span>
</div>
</div>
</div>
</Card>
{/* Seasonal Insights */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Patrones Estacionales</h3>
<div className="space-y-3">
{seasonalInsights.map((insight, index) => (
<div key={index} className="p-3 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Factor de demanda:</span>
<span className="text-sm font-medium text-[var(--color-info)]">{weatherImpact.demandFactor}x</span>
</div>
{weatherImpact.affectedCategories.length > 0 && (
<div className="mt-4">
<p className="text-xs text-[var(--text-tertiary)] mb-2">Categorías afectadas:</p>
<div className="flex flex-wrap gap-1">
{weatherImpact.affectedCategories.map((category, index) => (
<Badge key={index} variant="blue">{category}</Badge>
))}
</div>
</div>
)}
</div>
</Card>
)}
{/* Model Performance */}
{statisticsData?.model_performance && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Rendimiento del Modelo</h3>
<div className="space-y-3">
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{insight.period}</span>
<span className="text-sm text-purple-600 font-medium">{insight.factor}x</span>
<span className="text-sm font-medium">Algoritmo principal</span>
<Badge variant="purple">{statisticsData.model_performance.most_used_algorithm}</Badge>
</div>
<div className="flex flex-wrap gap-1">
{insight.products.map((product, idx) => (
<Badge key={idx} variant="purple">{product}</Badge>
))}
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-tertiary)]">Tiempo de procesamiento promedio</span>
<span className="text-xs text-[var(--text-secondary)]">
{Math.round(statisticsData.model_performance.average_processing_time)}ms
</span>
</div>
</div>
))}
</div>
</Card>
</div>
</Card>
)}
</div>
</div>
{/* Detailed Forecasts Table */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Predicciones Detalladas</h3>
<Table
columns={forecastColumns}
data={mockForecasts}
rowKey="id"
hover={true}
variant="default"
size="md"
/>
</Card>
{!isLoading && !hasError && transformedForecasts.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Predicciones Detalladas</h3>
<Table
columns={forecastColumns}
data={transformedForecasts}
rowKey="id"
hover={true}
variant="default"
size="md"
/>
</Card>
)}
{!isLoading && !hasError && transformedForecasts.length === 0 && (
<Card className="p-6 text-center">
<div className="py-8">
<BarChart3 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-600 mb-2">No hay predicciones disponibles</h3>
<p className="text-gray-500">
No se encontraron predicciones para el período seleccionado.
Prueba ajustando los filtros o genera nuevas predicciones.
</p>
</div>
</Card>
)}
</div>
);
};

View File

@@ -3,7 +3,11 @@ import { Store, MapPin, Clock, Phone, Mail, Globe, Save, X, Edit3, Zap, Plus, Se
import { Button, Card, Input, Select, Modal, Badge } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { posService, POSConfiguration } from '../../../../api/services/pos.service';
import { usePOSConfigurationData, usePOSConfigurationManager } from '../../../../api/hooks/pos';
import { POSConfiguration, POSProviderConfig } from '../../../../api/types/pos';
import { posService } from '../../../../api/services/pos';
import { useTenant, useUpdateTenant } from '../../../../api/hooks/tenant';
import { useAuthUser } from '../../../../stores/auth.store';
interface BakeryConfig {
// General Info
@@ -32,33 +36,25 @@ interface BusinessHours {
};
}
interface POSProviderConfig {
id: string;
name: string;
logo: string;
description: string;
features: string[];
required_fields: {
field: string;
label: string;
type: 'text' | 'password' | 'url' | 'select';
placeholder?: string;
required: boolean;
help_text?: string;
options?: { value: string; label: string }[];
}[];
}
const BakeryConfigPage: React.FC = () => {
const { addToast } = useToast();
const user = useAuthUser();
const tenantId = user?.tenant_id || '';
const { data: tenant, isLoading: tenantLoading, error: tenantError } = useTenant(tenantId, { enabled: !!tenantId });
const updateTenantMutation = useUpdateTenant();
// POS Configuration hooks
const posData = usePOSConfigurationData(tenantId);
const posManager = usePOSConfigurationManager(tenantId);
const [activeTab, setActiveTab] = useState<'general' | 'location' | 'business' | 'hours' | 'pos'>('general');
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// POS Configuration State
const [posConfigurations, setPosConfigurations] = useState<POSConfiguration[]>([]);
const [posLoading, setPosLoading] = useState(false);
const [showAddPosModal, setShowAddPosModal] = useState(false);
const [showEditPosModal, setShowEditPosModal] = useState(false);
const [selectedPosConfig, setSelectedPosConfig] = useState<POSConfiguration | null>(null);
@@ -67,20 +63,41 @@ const BakeryConfigPage: React.FC = () => {
const [showCredentials, setShowCredentials] = useState<Record<string, boolean>>({});
const [config, setConfig] = useState<BakeryConfig>({
name: 'Panadería Artesanal San Miguel',
description: 'Panadería tradicional con más de 30 años de experiencia',
email: 'info@panaderiasanmiguel.com',
phone: '+34 912 345 678',
website: 'https://panaderiasanmiguel.com',
address: 'Calle Mayor 123',
city: 'Madrid',
postalCode: '28001',
country: 'España',
taxId: 'B12345678',
name: '',
description: '',
email: '',
phone: '',
website: '',
address: '',
city: '',
postalCode: '',
country: '',
taxId: '',
currency: 'EUR',
timezone: 'Europe/Madrid',
language: 'es'
});
// Update config when tenant data is loaded
React.useEffect(() => {
if (tenant) {
setConfig({
name: tenant.name || '',
description: tenant.description || '',
email: tenant.email || '', // Fixed: use email instead of contact_email
phone: tenant.phone || '', // Fixed: use phone instead of contact_phone
website: tenant.website || '',
address: tenant.address || '',
city: tenant.city || '',
postalCode: tenant.postal_code || '',
country: tenant.country || '',
taxId: '', // Not supported by backend yet
currency: 'EUR', // Default value
timezone: 'Europe/Madrid', // Default value
language: 'es' // Default value
});
}
}, [tenant]);
const [businessHours, setBusinessHours] = useState<BusinessHours>({
monday: { open: '07:00', close: '20:00', closed: false },
@@ -185,27 +202,10 @@ const BakeryConfigPage: React.FC = () => {
{ value: 'en', label: 'English' }
];
// Load POS configurations when POS tab is selected
useEffect(() => {
if (activeTab === 'pos') {
loadPosConfigurations();
}
}, [activeTab]);
const loadPosConfigurations = async () => {
try {
setPosLoading(true);
const response = await posService.getPOSConfigs();
if (response.success) {
setPosConfigurations(response.data);
} else {
addToast('Error al cargar configuraciones POS', 'error');
}
} catch (error) {
addToast('Error al conectar con el servidor', 'error');
} finally {
setPosLoading(false);
}
// Load POS configurations function for refetching after updates
const loadPosConfigurations = () => {
// This will trigger a refetch of POS configurations
posManager.refetch();
};
const validateConfig = (): boolean => {
@@ -234,13 +234,26 @@ const BakeryConfigPage: React.FC = () => {
};
const handleSaveConfig = async () => {
if (!validateConfig()) return;
if (!validateConfig() || !tenantId) return;
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
await updateTenantMutation.mutateAsync({
tenantId,
updateData: {
name: config.name,
description: config.description,
email: config.email, // Fixed: use email instead of contact_email
phone: config.phone, // Fixed: use phone instead of contact_phone
website: config.website,
address: config.address,
city: config.city,
postal_code: config.postalCode,
country: config.country
// Note: tax_id, currency, timezone, language might not be supported by backend
}
});
setIsEditing(false);
addToast('Configuración actualizada correctamente', 'success');
@@ -317,24 +330,23 @@ const BakeryConfigPage: React.FC = () => {
if (selectedPosConfig) {
// Update existing
const response = await posService.updatePOSConfig(selectedPosConfig.id, posFormData);
if (response.success) {
addToast('Configuración actualizada correctamente', 'success');
setShowEditPosModal(false);
loadPosConfigurations();
} else {
addToast('Error al actualizar la configuración', 'error');
}
await posService.updatePOSConfiguration({
tenant_id: tenantId,
config_id: selectedPosConfig.id,
...posFormData,
});
addToast('Configuración actualizada correctamente', 'success');
setShowEditPosModal(false);
loadPosConfigurations();
} else {
// Create new
const response = await posService.createPOSConfig(posFormData);
if (response.success) {
addToast('Configuración creada correctamente', 'success');
setShowAddPosModal(false);
loadPosConfigurations();
} else {
addToast('Error al crear la configuración', 'error');
}
await posService.createPOSConfiguration({
tenant_id: tenantId,
...posFormData,
});
addToast('Configuración creada correctamente', 'success');
setShowAddPosModal(false);
loadPosConfigurations();
}
} catch (error) {
addToast('Error al guardar la configuración', 'error');
@@ -344,12 +356,15 @@ const BakeryConfigPage: React.FC = () => {
const handleTestPosConnection = async (configId: string) => {
try {
setTestingConnection(configId);
const response = await posService.testPOSConnection(configId);
const response = await posService.testPOSConnection({
tenant_id: tenantId,
config_id: configId,
});
if (response.success && response.data.success) {
if (response.success) {
addToast('Conexión exitosa', 'success');
} else {
addToast(`Error en la conexión: ${response.data.message}`, 'error');
addToast(`Error en la conexión: ${response.message || 'Error desconocido'}`, 'error');
}
} catch (error) {
addToast('Error al probar la conexión', 'error');
@@ -364,13 +379,12 @@ const BakeryConfigPage: React.FC = () => {
}
try {
const response = await posService.deletePOSConfig(configId);
if (response.success) {
addToast('Configuración eliminada correctamente', 'success');
loadPosConfigurations();
} else {
addToast('Error al eliminar la configuración', 'error');
}
await posService.deletePOSConfiguration({
tenant_id: tenantId,
config_id: configId,
});
addToast('Configuración eliminada correctamente', 'success');
loadPosConfigurations();
} catch (error) {
addToast('Error al eliminar la configuración', 'error');
}
@@ -543,6 +557,37 @@ const BakeryConfigPage: React.FC = () => {
);
};
if (tenantLoading) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Configuración de Panadería"
description="Configurando datos básicos y preferencias de tu panadería"
/>
<div className="flex items-center justify-center h-64">
<Loader className="w-8 h-8 animate-spin" />
<span className="ml-2">Cargando configuración...</span>
</div>
</div>
);
}
if (tenantError) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Configuración de Panadería"
description="Error al cargar la configuración"
/>
<Card className="p-6">
<div className="text-red-600">
Error al cargar la configuración: {tenantError.message}
</div>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6">
<PageHeader
@@ -829,11 +874,11 @@ const BakeryConfigPage: React.FC = () => {
</Button>
</div>
{posLoading ? (
{posData.isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader className="w-8 h-8 animate-spin" />
</div>
) : posConfigurations.length === 0 ? (
) : posData.configurations.length === 0 ? (
<Card className="p-8 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-gray-400" />
@@ -848,20 +893,20 @@ const BakeryConfigPage: React.FC = () => {
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posConfigurations.map(config => {
const provider = supportedProviders.find(p => p.id === config.provider);
{posData.configurations.map(config => {
const provider = posData.supportedSystems.find(p => p.id === config.pos_system);
return (
<Card key={config.id} className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className="text-2xl mr-3">{provider?.logo || '📊'}</div>
<div className="text-2xl mr-3">📊</div>
<div>
<h3 className="font-medium">{config.config_name}</h3>
<p className="text-sm text-gray-500">{provider?.name || config.provider}</p>
<h3 className="font-medium">{config.provider_name}</h3>
<p className="text-sm text-gray-500">{provider?.name || config.pos_system}</p>
</div>
</div>
<div className="flex items-center space-x-1">
{config.is_active ? (
{config.is_connected ? (
<Wifi className="w-4 h-4 text-green-500" />
) : (
<WifiOff className="w-4 h-4 text-red-500" />

View File

@@ -2,8 +2,14 @@ import React, { useState } from 'react';
import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react';
import { Button, Card } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useAuthProfile, useUpdateProfile } from '../../../../api/hooks/auth';
import { useToast } from '../../../../hooks/ui/useToast';
const PreferencesPage: React.FC = () => {
const { addToast } = useToast();
const { data: profile, isLoading: profileLoading } = useAuthProfile();
const updateProfileMutation = useUpdateProfile();
const [preferences, setPreferences] = useState({
notifications: {
inventory: {
@@ -50,12 +56,31 @@ const PreferencesPage: React.FC = () => {
vibrationEnabled: true
},
channels: {
email: 'panaderia@example.com',
phone: '+34 600 123 456',
email: profile?.email || '',
phone: profile?.phone || '',
slack: false,
webhook: ''
}
});
// Update preferences when profile loads
React.useEffect(() => {
if (profile) {
setPreferences(prev => ({
...prev,
global: {
...prev.global,
language: profile.language || 'es',
timezone: profile.timezone || 'Europe/Madrid'
},
channels: {
...prev.channels,
email: profile.email || '',
phone: profile.phone || ''
}
}));
}
}, [profile]);
const [hasChanges, setHasChanges] = useState(false);
@@ -149,14 +174,49 @@ const PreferencesPage: React.FC = () => {
setHasChanges(true);
};
const handleSave = () => {
// Handle save logic
console.log('Saving preferences:', preferences);
setHasChanges(false);
const handleSave = async () => {
try {
// Save notification preferences and contact info
await updateProfileMutation.mutateAsync({
language: preferences.global.language,
timezone: preferences.global.timezone,
phone: preferences.channels.phone,
notification_preferences: preferences.notifications
});
addToast('Preferencias guardadas correctamente', 'success');
setHasChanges(false);
} catch (error) {
addToast('Error al guardar las preferencias', 'error');
}
};
const handleReset = () => {
// Reset to defaults
if (profile) {
setPreferences({
notifications: {
inventory: { app: true, email: false, sms: true, frequency: 'immediate' },
sales: { app: true, email: true, sms: false, frequency: 'hourly' },
production: { app: true, email: false, sms: true, frequency: 'immediate' },
system: { app: true, email: true, sms: false, frequency: 'daily' },
marketing: { app: false, email: true, sms: false, frequency: 'weekly' }
},
global: {
doNotDisturb: false,
quietHours: { enabled: false, start: '22:00', end: '07:00' },
language: profile.language || 'es',
timezone: profile.timezone || 'Europe/Madrid',
soundEnabled: true,
vibrationEnabled: true
},
channels: {
email: profile.email || '',
phone: profile.phone || '',
slack: false,
webhook: ''
}
});
}
setHasChanges(false);
};

View File

@@ -4,6 +4,7 @@ import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useAuthUser } from '../../../../stores/auth.store';
import { useToast } from '../../../../hooks/ui/useToast';
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
interface ProfileFormData {
first_name: string;
@@ -22,20 +23,38 @@ interface PasswordData {
const ProfilePage: React.FC = () => {
const user = useAuthUser();
const { showToast } = useToast();
const { addToast } = useToast();
const { data: profile, isLoading: profileLoading, error: profileError } = useAuthProfile();
const updateProfileMutation = useUpdateProfile();
const changePasswordMutation = useChangePassword();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [profileData, setProfileData] = useState<ProfileFormData>({
first_name: 'María',
last_name: 'González Pérez',
email: 'admin@bakery.com',
phone: '+34 612 345 678',
first_name: '',
last_name: '',
email: '',
phone: '',
language: 'es',
timezone: 'Europe/Madrid'
});
// Update profile data when profile is loaded
React.useEffect(() => {
if (profile) {
setProfileData({
first_name: profile.first_name || '',
last_name: profile.last_name || '',
email: profile.email || '',
phone: profile.phone || '',
language: profile.language || 'es',
timezone: profile.timezone || 'Europe/Madrid'
});
}
}, [profile]);
const [passwordData, setPasswordData] = useState<PasswordData>({
currentPassword: '',
@@ -105,48 +124,34 @@ const ProfilePage: React.FC = () => {
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
await updateProfileMutation.mutateAsync(profileData);
setIsEditing(false);
showToast({
type: 'success',
title: 'Perfil actualizado',
message: 'Tu información ha sido guardada correctamente'
});
addToast('Perfil actualizado correctamente', 'success');
} catch (error) {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo actualizar tu perfil'
});
addToast('No se pudo actualizar tu perfil', 'error');
} finally {
setIsLoading(false);
}
};
const handleChangePassword = async () => {
const handleChangePasswordSubmit = async () => {
if (!validatePassword()) return;
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
await changePasswordMutation.mutateAsync({
current_password: passwordData.currentPassword,
new_password: passwordData.newPassword,
confirm_password: passwordData.confirmPassword
});
setShowPasswordForm(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
showToast({
type: 'success',
title: 'Contraseña actualizada',
message: 'Tu contraseña ha sido cambiada correctamente'
});
addToast('Contraseña actualizada correctamente', 'success');
} catch (error) {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo cambiar tu contraseña'
});
addToast('No se pudo cambiar tu contraseña', 'error');
} finally {
setIsLoading(false);
}
@@ -182,7 +187,7 @@ const ProfilePage: React.FC = () => {
<div className="flex items-center gap-6">
<div className="relative">
<Avatar
src="https://images.unsplash.com/photo-1494790108755-2616b612b372?w=150&h=150&fit=crop&crop=face"
src={profile?.avatar_url}
name={`${profileData.first_name} ${profileData.last_name}`}
size="xl"
className="w-20 h-20"
@@ -362,7 +367,7 @@ const ProfilePage: React.FC = () => {
</Button>
<Button
variant="primary"
onClick={handleChangePassword}
onClick={handleChangePasswordSubmit}
isLoading={isLoading}
loadingText="Cambiando..."
>

View File

@@ -37,8 +37,7 @@ import {
subscriptionService,
type UsageSummary,
type AvailablePlans
} from '../../../../api/services';
import { isMockMode, getMockSubscription } from '../../../../config/mock.config';
} from '../../../../api';
interface PlanComparisonProps {
plans: AvailablePlans['plans'];
@@ -249,7 +248,7 @@ const SubscriptionPage: React.FC = () => {
const [upgrading, setUpgrading] = useState(false);
useEffect(() => {
if (currentTenant?.id || user?.tenant_id || isMockMode()) {
if (currentTenant?.id || user?.tenant_id) {
loadSubscriptionData();
}
}, [currentTenant, user?.tenant_id]);
@@ -257,15 +256,10 @@ const SubscriptionPage: React.FC = () => {
const loadSubscriptionData = async () => {
let tenantId = currentTenant?.id || user?.tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
tenantId = getMockSubscription().tenant_id;
console.log('🧪 Mock mode: Using mock tenant ID:', tenantId);
if (!tenantId) {
toast.error('No se encontró información del tenant');
return;
}
console.log('📊 Loading subscription data for tenant:', tenantId, '| Mock mode:', isMockMode());
if (!tenantId) return;
try {
setLoading(true);
@@ -292,12 +286,10 @@ const SubscriptionPage: React.FC = () => {
const handleUpgradeConfirm = async () => {
let tenantId = currentTenant?.id || user?.tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
tenantId = getMockSubscription().tenant_id;
if (!tenantId || !selectedPlan) {
toast.error('Información de tenant no disponible');
return;
}
if (!tenantId || !selectedPlan) return;
try {
setUpgrading(true);

View File

@@ -38,7 +38,6 @@ import {
type UsageSummary,
type AvailablePlans
} from '../../../../api/services';
import { isMockMode, getMockSubscription } from '../../../../config/mock.config';
interface PlanComparisonProps {
plans: AvailablePlans['plans'];
@@ -297,7 +296,7 @@ const SubscriptionPage: React.FC = () => {
const [upgrading, setUpgrading] = useState(false);
useEffect(() => {
if (currentTenant?.id || tenant_id || isMockMode()) {
if (currentTenant?.id || tenant_id) {
loadSubscriptionData();
}
}, [currentTenant, tenant_id]);
@@ -305,13 +304,7 @@ const SubscriptionPage: React.FC = () => {
const loadSubscriptionData = async () => {
let tenantId = currentTenant?.id || tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
tenantId = getMockSubscription().tenant_id;
console.log('🧪 Mock mode: Using mock tenant ID:', tenantId);
}
console.log('📊 Loading subscription data for tenant:', tenantId, '| Mock mode:', isMockMode());
console.log('📊 Loading subscription data for tenant:', tenantId);
if (!tenantId) return;
@@ -340,11 +333,6 @@ const SubscriptionPage: React.FC = () => {
const handleUpgradeConfirm = async () => {
let tenantId = currentTenant?.id || tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
tenantId = getMockSubscription().tenant_id;
}
if (!tenantId || !selectedPlan) return;
try {

View File

@@ -2,131 +2,31 @@ import React, { useState } from 'react';
import { Users, Plus, Search, Mail, Phone, Shield, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
import { Button, Card, Badge, Input } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useTeamMembers } from '../../../../api/hooks/tenant';
import { useAllUsers, useUpdateUser } from '../../../../api/hooks/user';
import { useAuthUser } from '../../../../stores/auth.store';
import { useToast } from '../../../../hooks/ui/useToast';
const TeamPage: React.FC = () => {
const { addToast } = useToast();
const user = useAuthUser();
const tenantId = user?.tenant_id || '';
const { data: teamMembers = [], isLoading, error } = useTeamMembers(tenantId, true, { enabled: !!tenantId });
const { data: allUsers = [] } = useAllUsers();
const updateUserMutation = useUpdateUser();
const [searchTerm, setSearchTerm] = useState('');
const [selectedRole, setSelectedRole] = useState('all');
const [showForm, setShowForm] = useState(false);
const teamMembers = [
{
id: '1',
name: 'María González',
email: 'maria.gonzalez@panaderia.com',
phone: '+34 600 123 456',
role: 'manager',
department: 'Administración',
status: 'active',
joinDate: '2022-03-15',
lastLogin: '2024-01-26 09:30:00',
permissions: ['inventory', 'sales', 'reports', 'team'],
avatar: '/avatars/maria.jpg',
schedule: {
monday: '07:00-15:00',
tuesday: '07:00-15:00',
wednesday: '07:00-15:00',
thursday: '07:00-15:00',
friday: '07:00-15:00',
saturday: 'Libre',
sunday: 'Libre'
}
},
{
id: '2',
name: 'Carlos Rodríguez',
email: 'carlos.rodriguez@panaderia.com',
phone: '+34 600 234 567',
role: 'baker',
department: 'Producción',
status: 'active',
joinDate: '2021-09-20',
lastLogin: '2024-01-26 08:45:00',
permissions: ['production', 'inventory'],
avatar: '/avatars/carlos.jpg',
schedule: {
monday: '05:00-13:00',
tuesday: '05:00-13:00',
wednesday: '05:00-13:00',
thursday: '05:00-13:00',
friday: '05:00-13:00',
saturday: '05:00-11:00',
sunday: 'Libre'
}
},
{
id: '3',
name: 'Ana Martínez',
email: 'ana.martinez@panaderia.com',
phone: '+34 600 345 678',
role: 'cashier',
department: 'Ventas',
status: 'active',
joinDate: '2023-01-10',
lastLogin: '2024-01-26 10:15:00',
permissions: ['sales', 'pos'],
avatar: '/avatars/ana.jpg',
schedule: {
monday: '08:00-16:00',
tuesday: '08:00-16:00',
wednesday: 'Libre',
thursday: '08:00-16:00',
friday: '08:00-16:00',
saturday: '09:00-14:00',
sunday: '09:00-14:00'
}
},
{
id: '4',
name: 'Luis Fernández',
email: 'luis.fernandez@panaderia.com',
phone: '+34 600 456 789',
role: 'baker',
department: 'Producción',
status: 'inactive',
joinDate: '2020-11-05',
lastLogin: '2024-01-20 16:30:00',
permissions: ['production'],
avatar: '/avatars/luis.jpg',
schedule: {
monday: '13:00-21:00',
tuesday: '13:00-21:00',
wednesday: '13:00-21:00',
thursday: 'Libre',
friday: '13:00-21:00',
saturday: 'Libre',
sunday: '13:00-21:00'
}
},
{
id: '5',
name: 'Isabel Torres',
email: 'isabel.torres@panaderia.com',
phone: '+34 600 567 890',
role: 'assistant',
department: 'Ventas',
status: 'active',
joinDate: '2023-06-01',
lastLogin: '2024-01-25 18:20:00',
permissions: ['sales'],
avatar: '/avatars/isabel.jpg',
schedule: {
monday: 'Libre',
tuesday: '16:00-20:00',
wednesday: '16:00-20:00',
thursday: '16:00-20:00',
friday: '16:00-20:00',
saturday: '14:00-20:00',
sunday: '14:00-20:00'
}
}
];
const roles = [
{ value: 'all', label: 'Todos los Roles', count: teamMembers.length },
{ value: 'owner', label: 'Propietario', count: teamMembers.filter(m => m.role === 'owner').length },
{ value: 'admin', label: 'Administrador', count: teamMembers.filter(m => m.role === 'admin').length },
{ value: 'manager', label: 'Gerente', count: teamMembers.filter(m => m.role === 'manager').length },
{ value: 'baker', label: 'Panadero', count: teamMembers.filter(m => m.role === 'baker').length },
{ value: 'cashier', label: 'Cajero', count: teamMembers.filter(m => m.role === 'cashier').length },
{ value: 'assistant', label: 'Asistente', count: teamMembers.filter(m => m.role === 'assistant').length }
{ value: 'employee', label: 'Empleado', count: teamMembers.filter(m => m.role === 'employee').length }
];
const teamStats = {
@@ -165,10 +65,28 @@ const TeamPage: React.FC = () => {
const filteredMembers = teamMembers.filter(member => {
const matchesRole = selectedRole === 'all' || member.role === selectedRole;
const matchesSearch = member.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.email.toLowerCase().includes(searchTerm.toLowerCase());
const matchesSearch = member.user.first_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.user.last_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.user.email.toLowerCase().includes(searchTerm.toLowerCase());
return matchesRole && matchesSearch;
});
if (isLoading) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Gestión de Equipo"
description="Administra los miembros del equipo, roles y permisos"
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p>Cargando miembros del equipo...</p>
</div>
</div>
</div>
);
}
const formatLastLogin = (timestamp: string) => {
const date = new Date(timestamp);
@@ -293,7 +211,7 @@ const TeamPage: React.FC = () => {
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{member.name}</h3>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{member.user.first_name} {member.user.last_name}</h3>
<Badge variant={getStatusColor(member.status)}>
{member.status === 'active' ? 'Activo' : 'Inactivo'}
</Badge>
@@ -302,11 +220,11 @@ const TeamPage: React.FC = () => {
<div className="space-y-1 mb-3">
<div className="flex items-center text-sm text-[var(--text-secondary)]">
<Mail className="w-4 h-4 mr-2" />
{member.email}
{member.user.email}
</div>
<div className="flex items-center text-sm text-[var(--text-secondary)]">
<Phone className="w-4 h-4 mr-2" />
{member.phone}
{member.user.phone || 'No disponible'}
</div>
</div>
@@ -320,35 +238,24 @@ const TeamPage: React.FC = () => {
</div>
<div className="text-sm text-[var(--text-tertiary)] mb-3">
<p>Se unió: {new Date(member.joinDate).toLocaleDateString('es-ES')}</p>
<p>Última conexión: {formatLastLogin(member.lastLogin)}</p>
<p>Se unió: {new Date(member.joined_at).toLocaleDateString('es-ES')}</p>
<p>Estado: {member.is_active ? 'Activo' : 'Inactivo'}</p>
</div>
{/* Permissions */}
<div className="mb-3">
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">Permisos:</p>
<div className="flex flex-wrap gap-1">
{member.permissions.map((permission, index) => (
<span
key={index}
className="px-2 py-1 bg-[var(--color-info)]/10 text-[var(--color-info)] text-xs rounded-full"
>
{permission}
</span>
))}
<span className="px-2 py-1 bg-[var(--color-info)]/10 text-[var(--color-info)] text-xs rounded-full">
{getRoleLabel(member.role)}
</span>
</div>
</div>
{/* Schedule Preview */}
{/* Member Info */}
<div className="text-xs text-[var(--text-tertiary)]">
<p className="font-medium mb-1">Horario esta semana:</p>
<div className="grid grid-cols-2 gap-1">
{Object.entries(member.schedule).slice(0, 4).map(([day, hours]) => (
<span key={day}>
{day.charAt(0).toUpperCase()}: {hours}
</span>
))}
</div>
<p className="font-medium mb-1">Información adicional:</p>
<p>ID: {member.id}</p>
</div>
</div>
</div>