Improve the frontend and fix TODOs
This commit is contained in:
@@ -120,30 +120,30 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
stats={[
|
||||
{
|
||||
label: 'Planes Activos',
|
||||
value: dashboard?.stats?.total_plans || 0,
|
||||
value: dashboard?.summary?.total_plans || 0,
|
||||
icon: ShoppingCart,
|
||||
formatter: formatters.number
|
||||
},
|
||||
{
|
||||
label: 'Tasa de Cumplimiento',
|
||||
value: dashboard?.stats?.avg_fulfillment_rate || 0,
|
||||
value: dashboard?.performance_metrics?.average_fulfillment_rate || 0,
|
||||
icon: Target,
|
||||
formatter: formatters.percentage,
|
||||
change: dashboard?.stats?.fulfillment_trend
|
||||
change: dashboard?.performance_metrics?.fulfillment_trend
|
||||
},
|
||||
{
|
||||
label: 'Entregas a Tiempo',
|
||||
value: dashboard?.stats?.avg_on_time_delivery || 0,
|
||||
value: dashboard?.performance_metrics?.average_on_time_delivery || 0,
|
||||
icon: Calendar,
|
||||
formatter: formatters.percentage,
|
||||
change: dashboard?.stats?.on_time_trend
|
||||
change: dashboard?.performance_metrics?.on_time_trend
|
||||
},
|
||||
{
|
||||
label: 'Variación de Costos',
|
||||
value: dashboard?.stats?.avg_cost_variance || 0,
|
||||
value: dashboard?.performance_metrics?.cost_accuracy || 0,
|
||||
icon: DollarSign,
|
||||
formatter: formatters.percentage,
|
||||
change: dashboard?.stats?.cost_variance_trend
|
||||
change: dashboard?.performance_metrics?.cost_variance_trend
|
||||
}
|
||||
]}
|
||||
loading={dashboardLoading}
|
||||
@@ -176,7 +176,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-primary)]"
|
||||
style={{ width: `${(status.count / dashboard.stats.total_plans) * 100}%` }}
|
||||
style={{ width: `${(status.count / (dashboard?.summary?.total_plans || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)] w-8 text-right">
|
||||
@@ -275,7 +275,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
<div className="p-6 text-center">
|
||||
<Target className="mx-auto h-8 w-8 text-[var(--color-success)] mb-3" />
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{formatters.percentage(dashboard?.stats?.avg_fulfillment_rate || 0)}
|
||||
{formatters.percentage(dashboard?.performance_metrics?.average_fulfillment_rate || 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Tasa de Cumplimiento</div>
|
||||
</div>
|
||||
@@ -285,7 +285,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
<div className="p-6 text-center">
|
||||
<Calendar className="mx-auto h-8 w-8 text-[var(--color-info)] mb-3" />
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{formatters.percentage(dashboard?.stats?.avg_on_time_delivery || 0)}
|
||||
{formatters.percentage(dashboard?.performance_metrics?.average_on_time_delivery || 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Entregas a Tiempo</div>
|
||||
</div>
|
||||
@@ -295,7 +295,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
<div className="p-6 text-center">
|
||||
<Award className="mx-auto h-8 w-8 text-[var(--color-warning)] mb-3" />
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{dashboard?.stats?.avg_quality_score?.toFixed(1) || '0.0'}
|
||||
{dashboard?.performance_metrics?.supplier_performance?.toFixed(1) || '0.0'}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Puntuación de Calidad</div>
|
||||
</div>
|
||||
@@ -372,23 +372,23 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Costo Total Estimado</span>
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
€{formatters.currency(dashboard?.cost_analysis?.total_estimated || 0)}
|
||||
€{formatters.currency(dashboard?.summary?.total_estimated_cost || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Costo Total Aprobado</span>
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
€{formatters.currency(dashboard?.cost_analysis?.total_approved || 0)}
|
||||
€{formatters.currency(dashboard?.summary?.total_approved_cost || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Variación Promedio</span>
|
||||
<span className={`text-2xl font-bold ${
|
||||
(dashboard?.cost_analysis?.avg_variance || 0) > 0
|
||||
(dashboard?.summary?.cost_variance || 0) > 0
|
||||
? 'text-[var(--color-error)]'
|
||||
: 'text-[var(--color-success)]'
|
||||
}`}>
|
||||
{formatters.percentage(Math.abs(dashboard?.cost_analysis?.avg_variance || 0))}
|
||||
€{formatters.currency(Math.abs(dashboard?.summary?.cost_variance || 0))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,7 +408,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-primary)]"
|
||||
style={{ width: `${(category.amount / dashboard.cost_analysis.total_estimated) * 100}%` }}
|
||||
style={{ width: `${(category.amount / (dashboard?.summary?.total_estimated_cost || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)] w-20 text-right">
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Store, MapPin, Clock, Phone, Mail, Globe, Save, X, AlertCircle, Loader } from 'lucide-react';
|
||||
import { Button, Card, Input, Select } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useUpdateTenant } from '../../../../api/hooks/tenant';
|
||||
import { useCurrentTenant, useTenantActions } from '../../../../stores/tenant.store';
|
||||
|
||||
interface BakeryConfig {
|
||||
// General Info
|
||||
name: string;
|
||||
description: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
website: string;
|
||||
// Location
|
||||
address: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
// Business
|
||||
taxId: string;
|
||||
currency: string;
|
||||
timezone: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface BusinessHours {
|
||||
[key: string]: {
|
||||
open: string;
|
||||
close: string;
|
||||
closed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const InformationPage: React.FC = () => {
|
||||
const { addToast } = useToast();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { loadUserTenants, setCurrentTenant } = useTenantActions();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// Use the current tenant from the store instead of making additional API calls
|
||||
// to avoid the 422 validation error on the tenant GET endpoint
|
||||
const tenant = currentTenant;
|
||||
const tenantLoading = !currentTenant;
|
||||
const tenantError = null;
|
||||
|
||||
const updateTenantMutation = useUpdateTenant();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const [config, setConfig] = useState<BakeryConfig>({
|
||||
name: '',
|
||||
description: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
address: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: '',
|
||||
taxId: '',
|
||||
currency: 'EUR',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es'
|
||||
});
|
||||
|
||||
// Load user tenants on component mount to ensure fresh data
|
||||
React.useEffect(() => {
|
||||
loadUserTenants();
|
||||
}, [loadUserTenants]);
|
||||
|
||||
// Update config when tenant data is loaded
|
||||
React.useEffect(() => {
|
||||
if (tenant) {
|
||||
setConfig({
|
||||
name: tenant.name || '',
|
||||
description: tenant.description || '',
|
||||
email: tenant.email || '',
|
||||
phone: tenant.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
|
||||
});
|
||||
setHasUnsavedChanges(false); // Reset unsaved changes when loading fresh data
|
||||
}
|
||||
}, [tenant]);
|
||||
|
||||
const [businessHours, setBusinessHours] = useState<BusinessHours>({
|
||||
monday: { open: '07:00', close: '20:00', closed: false },
|
||||
tuesday: { open: '07:00', close: '20:00', closed: false },
|
||||
wednesday: { open: '07:00', close: '20:00', closed: false },
|
||||
thursday: { open: '07:00', close: '20:00', closed: false },
|
||||
friday: { open: '07:00', close: '20:00', closed: false },
|
||||
saturday: { open: '08:00', close: '14:00', closed: false },
|
||||
sunday: { open: '09:00', close: '13:00', closed: false }
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const daysOfWeek = [
|
||||
{ key: 'monday', label: 'Lunes' },
|
||||
{ key: 'tuesday', label: 'Martes' },
|
||||
{ key: 'wednesday', label: 'Miércoles' },
|
||||
{ key: 'thursday', label: 'Jueves' },
|
||||
{ key: 'friday', label: 'Viernes' },
|
||||
{ key: 'saturday', label: 'Sábado' },
|
||||
{ key: 'sunday', label: 'Domingo' }
|
||||
];
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: 'EUR', label: 'EUR (€)' },
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'GBP', label: 'GBP (£)' }
|
||||
];
|
||||
|
||||
const timezoneOptions = [
|
||||
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
|
||||
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
|
||||
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
|
||||
];
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'ca', label: 'Català' },
|
||||
{ value: 'en', label: 'English' }
|
||||
];
|
||||
|
||||
const validateConfig = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!config.name.trim()) {
|
||||
newErrors.name = 'El nombre es requerido';
|
||||
}
|
||||
|
||||
if (!config.email.trim()) {
|
||||
newErrors.email = 'El email es requerido';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(config.email)) {
|
||||
newErrors.email = 'Email inválido';
|
||||
}
|
||||
|
||||
if (!config.address.trim()) {
|
||||
newErrors.address = 'La dirección es requerida';
|
||||
}
|
||||
|
||||
if (!config.city.trim()) {
|
||||
newErrors.city = 'La ciudad es requerida';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!validateConfig() || !tenantId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
email: config.email,
|
||||
phone: config.phone,
|
||||
website: config.website,
|
||||
address: config.address,
|
||||
city: config.city,
|
||||
postal_code: config.postalCode,
|
||||
country: config.country
|
||||
};
|
||||
|
||||
const updatedTenant = await updateTenantMutation.mutateAsync({
|
||||
tenantId,
|
||||
updateData
|
||||
});
|
||||
|
||||
// Update the tenant store with the new data
|
||||
if (updatedTenant) {
|
||||
setCurrentTenant(updatedTenant);
|
||||
// Force reload tenant list to ensure cache consistency
|
||||
await loadUserTenants();
|
||||
|
||||
// Update localStorage to persist the changes
|
||||
const tenantStorage = localStorage.getItem('tenant-storage');
|
||||
if (tenantStorage) {
|
||||
const parsedStorage = JSON.parse(tenantStorage);
|
||||
if (parsedStorage.state && parsedStorage.state.currentTenant) {
|
||||
parsedStorage.state.currentTenant = updatedTenant;
|
||||
localStorage.setItem('tenant-storage', JSON.stringify(parsedStorage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
addToast('Información actualizada correctamente', { type: 'success' });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||
addToast(`Error al actualizar: ${errorMessage}`, { type: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof BakeryConfig) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setConfig(prev => ({ ...prev, [field]: e.target.value }));
|
||||
setHasUnsavedChanges(true);
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = (field: keyof BakeryConfig) => (value: string) => {
|
||||
setConfig(prev => ({ ...prev, [field]: value }));
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleHoursChange = (day: string, field: 'open' | 'close' | 'closed', value: string | boolean) => {
|
||||
setBusinessHours(prev => ({
|
||||
...prev,
|
||||
[day]: {
|
||||
...prev[day],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
if (tenantLoading || !currentTenant) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Información de la Panadería"
|
||||
description="Configura los 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 información...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tenantError) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Información de la Panadería"
|
||||
description="Error al cargar la información"
|
||||
/>
|
||||
<Card className="p-6">
|
||||
<div className="text-red-600">
|
||||
Error al cargar la información: Error desconocido
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Información de la Panadería"
|
||||
description="Configura los datos básicos y preferencias de tu panadería"
|
||||
/>
|
||||
|
||||
{/* Bakery Header */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-xl">
|
||||
{config.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-1">
|
||||
{config.name}
|
||||
</h1>
|
||||
<p className="text-text-secondary">{config.email}</p>
|
||||
<p className="text-text-tertiary text-sm">{config.address}, {config.city}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{hasUnsavedChanges && (
|
||||
<div className="flex items-center gap-2 text-sm text-yellow-600">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Cambios sin guardar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Information Sections */}
|
||||
<div className="space-y-8">
|
||||
{/* General Information */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-6 flex items-center">
|
||||
<Store className="w-5 h-5 mr-2" />
|
||||
Información General
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<Input
|
||||
label="Nombre de la Panadería"
|
||||
value={config.name}
|
||||
onChange={handleInputChange('name')}
|
||||
error={errors.name}
|
||||
disabled={isLoading}
|
||||
placeholder="Nombre de tu panadería"
|
||||
leftIcon={<Store className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label="Email de Contacto"
|
||||
value={config.email}
|
||||
onChange={handleInputChange('email')}
|
||||
error={errors.email}
|
||||
disabled={isLoading}
|
||||
placeholder="contacto@panaderia.com"
|
||||
leftIcon={<Mail className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="tel"
|
||||
label="Teléfono"
|
||||
value={config.phone}
|
||||
onChange={handleInputChange('phone')}
|
||||
error={errors.phone}
|
||||
disabled={isLoading}
|
||||
placeholder="+34 912 345 678"
|
||||
leftIcon={<Phone className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Sitio Web"
|
||||
value={config.website}
|
||||
onChange={handleInputChange('website')}
|
||||
disabled={isLoading}
|
||||
placeholder="https://tu-panaderia.com"
|
||||
leftIcon={<Globe className="w-4 h-4" />}
|
||||
className="md:col-span-2 xl:col-span-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={config.description}
|
||||
onChange={handleInputChange('description')}
|
||||
disabled={isLoading}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg resize-none bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
||||
placeholder="Describe tu panadería..."
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Location Information */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-6 flex items-center">
|
||||
<MapPin className="w-5 h-5 mr-2" />
|
||||
Ubicación
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<Input
|
||||
label="Dirección"
|
||||
value={config.address}
|
||||
onChange={handleInputChange('address')}
|
||||
error={errors.address}
|
||||
disabled={isLoading}
|
||||
placeholder="Calle, número, etc."
|
||||
leftIcon={<MapPin className="w-4 h-4" />}
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Ciudad"
|
||||
value={config.city}
|
||||
onChange={handleInputChange('city')}
|
||||
error={errors.city}
|
||||
disabled={isLoading}
|
||||
placeholder="Ciudad"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Código Postal"
|
||||
value={config.postalCode}
|
||||
onChange={handleInputChange('postalCode')}
|
||||
disabled={isLoading}
|
||||
placeholder="28001"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="País"
|
||||
value={config.country}
|
||||
onChange={handleInputChange('country')}
|
||||
disabled={isLoading}
|
||||
placeholder="España"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Business Information */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-6">Datos de Empresa</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<Input
|
||||
label="NIF/CIF"
|
||||
value={config.taxId}
|
||||
onChange={handleInputChange('taxId')}
|
||||
disabled={isLoading}
|
||||
placeholder="B12345678"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Moneda"
|
||||
options={currencyOptions}
|
||||
value={config.currency}
|
||||
onChange={(value) => handleSelectChange('currency')(value as string)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Zona Horaria"
|
||||
options={timezoneOptions}
|
||||
value={config.timezone}
|
||||
onChange={(value) => handleSelectChange('timezone')(value as string)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Idioma"
|
||||
options={languageOptions}
|
||||
value={config.language}
|
||||
onChange={(value) => handleSelectChange('language')(value as string)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Business Hours */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-6 flex items-center">
|
||||
<Clock className="w-5 h-5 mr-2" />
|
||||
Horarios de Apertura
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{daysOfWeek.map((day) => {
|
||||
const hours = businessHours[day.key];
|
||||
return (
|
||||
<div key={day.key} className="grid grid-cols-12 items-center gap-4 p-4 border border-border-primary rounded-lg">
|
||||
{/* Day Name */}
|
||||
<div className="col-span-2">
|
||||
<span className="text-sm font-medium text-text-secondary">{day.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Closed Checkbox */}
|
||||
<div className="col-span-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hours.closed}
|
||||
onChange={(e) => handleHoursChange(day.key, 'closed', e.target.checked)}
|
||||
disabled={isLoading}
|
||||
className="rounded border-border-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">Cerrado</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Time Inputs */}
|
||||
<div className="col-span-8 flex items-center gap-6">
|
||||
{!hours.closed ? (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-tertiary mb-1">Apertura</label>
|
||||
<input
|
||||
type="time"
|
||||
value={hours.open}
|
||||
onChange={(e) => handleHoursChange(day.key, 'open', e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-tertiary mb-1">Cierre</label>
|
||||
<input
|
||||
type="time"
|
||||
value={hours.close}
|
||||
onChange={(e) => handleHoursChange(day.key, 'close', e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-text-tertiary italic">
|
||||
Cerrado todo el día
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Floating Save Button */}
|
||||
{hasUnsavedChanges && (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<Card className="p-4 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||
Tienes cambios sin guardar
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Reset to original values
|
||||
if (tenant) {
|
||||
setConfig({
|
||||
name: tenant.name || '',
|
||||
description: tenant.description || '',
|
||||
email: tenant.email || '',
|
||||
phone: tenant.phone || '',
|
||||
website: tenant.website || '',
|
||||
address: tenant.address || '',
|
||||
city: tenant.city || '',
|
||||
postalCode: tenant.postal_code || '',
|
||||
country: tenant.country || '',
|
||||
taxId: '',
|
||||
currency: 'EUR',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es'
|
||||
});
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Descartar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveConfig}
|
||||
isLoading={isLoading}
|
||||
loadingText="Guardando..."
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InformationPage;
|
||||
@@ -61,12 +61,13 @@ const InventoryPage: React.FC = () => {
|
||||
isLoading: analyticsLoading
|
||||
} = useStockAnalytics(tenantId);
|
||||
|
||||
// TODO: Implement expired stock API endpoint
|
||||
// Expiring stock data (already implemented via useExpiringStock hook)
|
||||
// Uncomment below if you need to display expired stock separately:
|
||||
// const {
|
||||
// data: expiredStockData,
|
||||
// isLoading: expiredStockLoading,
|
||||
// error: expiredStockError
|
||||
// } = useExpiredStock(tenantId);
|
||||
// data: expiringStockData,
|
||||
// isLoading: expiringStockLoading,
|
||||
// error: expiringStockError
|
||||
// } = useExpiringStock(tenantId, 7); // items expiring within 7 days
|
||||
|
||||
// Stock movements for history modal
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle } from 'lucide-react';
|
||||
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle, Play } from 'lucide-react';
|
||||
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import { statusColors } from '../../../../styles/colors';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useActiveBatches,
|
||||
useCreateProductionBatch,
|
||||
useUpdateBatchStatus,
|
||||
useTriggerProductionScheduler,
|
||||
productionService
|
||||
} from '../../../../api';
|
||||
import type {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from '../../../../api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ProcessStage } from '../../../../api/types/qualityTemplates';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const ProductionPage: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -56,6 +58,7 @@ const ProductionPage: React.FC = () => {
|
||||
// Mutations
|
||||
const createBatchMutation = useCreateProductionBatch();
|
||||
const updateBatchStatusMutation = useUpdateBatchStatus();
|
||||
const triggerSchedulerMutation = useTriggerProductionScheduler();
|
||||
|
||||
// Handlers
|
||||
const handleCreateBatch = async (batchData: ProductionBatchCreate) => {
|
||||
@@ -70,6 +73,16 @@ const ProductionPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerScheduler = async () => {
|
||||
try {
|
||||
await triggerSchedulerMutation.mutateAsync(tenantId);
|
||||
toast.success('Scheduler ejecutado exitosamente');
|
||||
} catch (error) {
|
||||
console.error('Error triggering scheduler:', error);
|
||||
toast.error('Error al ejecutar scheduler');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage management handlers
|
||||
const handleStageAdvance = async (batchId: string, currentStage: ProcessStage) => {
|
||||
const stages = Object.values(ProcessStage);
|
||||
@@ -283,21 +296,30 @@ const ProductionPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<PageHeader
|
||||
title="Gestión de Producción"
|
||||
description="Planifica y controla la producción diaria de tu panadería"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2.5 px-6 py-3 text-sm font-semibold tracking-wide shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<PlusCircle className="w-5 h-5" />
|
||||
Nueva Orden de Producción
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Gestión de Producción"
|
||||
description="Planifica y controla la producción diaria de tu panadería"
|
||||
actions={[
|
||||
{
|
||||
id: 'trigger-scheduler',
|
||||
label: triggerSchedulerMutation.isPending ? 'Ejecutando...' : 'Ejecutar Scheduler',
|
||||
icon: Play,
|
||||
onClick: handleTriggerScheduler,
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
disabled: triggerSchedulerMutation.isPending,
|
||||
loading: triggerSchedulerMutation.isPending
|
||||
},
|
||||
{
|
||||
id: 'create-batch',
|
||||
label: 'Nueva Orden de Producción',
|
||||
icon: PlusCircle,
|
||||
onClick: () => setShowCreateModal(true),
|
||||
variant: 'primary',
|
||||
size: 'md'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Production Stats */}
|
||||
<StatsGrid
|
||||
|
||||
@@ -317,48 +317,48 @@ const SuppliersPage: React.FC = () => {
|
||||
icon: Users,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
label: t('common:fields.name'),
|
||||
value: selectedSupplier.name || '',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
highlight: true,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Nombre del proveedor'
|
||||
placeholder: t('suppliers:placeholders.name')
|
||||
},
|
||||
{
|
||||
label: 'Persona de Contacto',
|
||||
label: t('common:fields.contact_person'),
|
||||
value: selectedSupplier.contact_person || '',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Nombre del contacto'
|
||||
placeholder: t('suppliers:placeholders.contact_person')
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
label: t('common:fields.email'),
|
||||
value: selectedSupplier.email || '',
|
||||
type: 'email',
|
||||
type: 'email' as const,
|
||||
editable: true,
|
||||
placeholder: 'email@ejemplo.com'
|
||||
placeholder: t('common:fields.email_placeholder')
|
||||
},
|
||||
{
|
||||
label: 'Teléfono',
|
||||
label: t('common:fields.phone'),
|
||||
value: selectedSupplier.phone || '',
|
||||
type: 'tel',
|
||||
type: 'tel' as const,
|
||||
editable: true,
|
||||
placeholder: '+34 123 456 789'
|
||||
placeholder: t('common:fields.phone_placeholder')
|
||||
},
|
||||
{
|
||||
label: 'Ciudad',
|
||||
label: t('common:fields.city'),
|
||||
value: selectedSupplier.city || '',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Ciudad'
|
||||
placeholder: t('common:fields.city')
|
||||
},
|
||||
{
|
||||
label: 'País',
|
||||
label: t('common:fields.country'),
|
||||
value: selectedSupplier.country || '',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'País'
|
||||
placeholder: t('common:fields.country')
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -367,90 +367,94 @@ const SuppliersPage: React.FC = () => {
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: 'Código de Proveedor',
|
||||
label: t('suppliers:labels.supplier_code'),
|
||||
value: selectedSupplier.supplier_code || '',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
highlight: true,
|
||||
editable: true,
|
||||
placeholder: 'Código único'
|
||||
placeholder: t('suppliers:placeholders.supplier_code')
|
||||
},
|
||||
{
|
||||
label: 'Tipo de Proveedor',
|
||||
value: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
|
||||
type: 'select',
|
||||
label: t('suppliers:labels.supplier_type'),
|
||||
value: modalMode === 'view'
|
||||
? getSupplierTypeText(selectedSupplier.supplier_type || SupplierType.INGREDIENTS)
|
||||
: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
|
||||
type: modalMode === 'view' ? 'text' as const : 'select' as const,
|
||||
editable: true,
|
||||
options: Object.values(SupplierType).map(value => ({
|
||||
options: modalMode === 'edit' ? Object.values(SupplierType).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:types.${value.toLowerCase()}`)
|
||||
}))
|
||||
})) : undefined
|
||||
},
|
||||
{
|
||||
label: 'Condiciones de Pago',
|
||||
value: selectedSupplier.payment_terms || PaymentTerms.NET_30,
|
||||
type: 'select',
|
||||
label: t('suppliers:labels.payment_terms'),
|
||||
value: modalMode === 'view'
|
||||
? getPaymentTermsText(selectedSupplier.payment_terms || PaymentTerms.NET_30)
|
||||
: selectedSupplier.payment_terms || PaymentTerms.NET_30,
|
||||
type: modalMode === 'view' ? 'text' as const : 'select' as const,
|
||||
editable: true,
|
||||
options: Object.values(PaymentTerms).map(value => ({
|
||||
options: modalMode === 'edit' ? Object.values(PaymentTerms).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
|
||||
}))
|
||||
})) : undefined
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de Entrega (días)',
|
||||
label: t('suppliers:labels.lead_time'),
|
||||
value: selectedSupplier.standard_lead_time || 3,
|
||||
type: 'number',
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
placeholder: '3'
|
||||
},
|
||||
{
|
||||
label: 'Pedido Mínimo',
|
||||
label: t('suppliers:labels.minimum_order'),
|
||||
value: selectedSupplier.minimum_order_amount || 0,
|
||||
type: 'currency',
|
||||
type: 'currency' as const,
|
||||
editable: true,
|
||||
placeholder: '0.00'
|
||||
},
|
||||
{
|
||||
label: 'Límite de Crédito',
|
||||
label: t('suppliers:labels.credit_limit'),
|
||||
value: selectedSupplier.credit_limit || 0,
|
||||
type: 'currency',
|
||||
type: 'currency' as const,
|
||||
editable: true,
|
||||
placeholder: '0.00'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Estadísticas',
|
||||
title: t('suppliers:sections.performance'),
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: 'Moneda',
|
||||
label: t('suppliers:labels.currency'),
|
||||
value: selectedSupplier.currency || 'EUR',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'EUR'
|
||||
},
|
||||
{
|
||||
label: 'Fecha de Creación',
|
||||
label: t('suppliers:labels.created_date'),
|
||||
value: selectedSupplier.created_at,
|
||||
type: 'datetime',
|
||||
type: 'datetime' as const,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Última Actualización',
|
||||
label: t('suppliers:labels.updated_date'),
|
||||
value: selectedSupplier.updated_at,
|
||||
type: 'datetime'
|
||||
type: 'datetime' as const
|
||||
}
|
||||
]
|
||||
},
|
||||
...(selectedSupplier.notes ? [{
|
||||
title: 'Notas',
|
||||
title: t('suppliers:sections.notes'),
|
||||
fields: [
|
||||
{
|
||||
label: 'Observaciones',
|
||||
label: t('suppliers:labels.notes'),
|
||||
value: selectedSupplier.notes,
|
||||
type: 'list',
|
||||
type: 'list' as const,
|
||||
span: 2 as const,
|
||||
editable: true,
|
||||
placeholder: 'Notas sobre el proveedor'
|
||||
placeholder: t('suppliers:placeholders.notes')
|
||||
}
|
||||
]
|
||||
}] : [])
|
||||
@@ -472,7 +476,7 @@ const SuppliersPage: React.FC = () => {
|
||||
statusIndicator={isCreating ? undefined : getSupplierStatusConfig(selectedSupplier.status)}
|
||||
size="lg"
|
||||
sections={sections}
|
||||
showDefaultActions={true}
|
||||
showDefaultActions={modalMode === 'edit'}
|
||||
onSave={async () => {
|
||||
// TODO: Implement save functionality
|
||||
console.log('Saving supplier:', selectedSupplier);
|
||||
@@ -485,20 +489,20 @@ const SuppliersPage: React.FC = () => {
|
||||
|
||||
// Map field labels to supplier properties
|
||||
const fieldMapping: { [key: string]: string } = {
|
||||
'Nombre': 'name',
|
||||
'Persona de Contacto': 'contact_person',
|
||||
'Email': 'email',
|
||||
'Teléfono': 'phone',
|
||||
'Ciudad': 'city',
|
||||
'País': 'country',
|
||||
'Código de Proveedor': 'supplier_code',
|
||||
'Tipo de Proveedor': 'supplier_type',
|
||||
'Condiciones de Pago': 'payment_terms',
|
||||
'Tiempo de Entrega (días)': 'standard_lead_time',
|
||||
'Pedido Mínimo': 'minimum_order_amount',
|
||||
'Límite de Crédito': 'credit_limit',
|
||||
'Moneda': 'currency',
|
||||
'Observaciones': 'notes'
|
||||
[t('common:fields.name')]: 'name',
|
||||
[t('common:fields.contact_person')]: 'contact_person',
|
||||
[t('common:fields.email')]: 'email',
|
||||
[t('common:fields.phone')]: 'phone',
|
||||
[t('common:fields.city')]: 'city',
|
||||
[t('common:fields.country')]: 'country',
|
||||
[t('suppliers:labels.supplier_code')]: 'supplier_code',
|
||||
[t('suppliers:labels.supplier_type')]: 'supplier_type',
|
||||
[t('suppliers:labels.payment_terms')]: 'payment_terms',
|
||||
[t('suppliers:labels.lead_time')]: 'standard_lead_time',
|
||||
[t('suppliers:labels.minimum_order')]: 'minimum_order_amount',
|
||||
[t('suppliers:labels.credit_limit')]: 'credit_limit',
|
||||
[t('suppliers:labels.currency')]: 'currency',
|
||||
[t('suppliers:labels.notes')]: 'notes'
|
||||
};
|
||||
|
||||
const propertyName = fieldMapping[field.label];
|
||||
@@ -514,4 +518,4 @@ const SuppliersPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SuppliersPage;
|
||||
export default SuppliersPage;
|
||||
|
||||
734
frontend/src/pages/app/settings/bakery/BakerySettingsPage.tsx
Normal file
734
frontend/src/pages/app/settings/bakery/BakerySettingsPage.tsx
Normal file
@@ -0,0 +1,734 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Store, MapPin, Clock, Settings as SettingsIcon, Save, X, AlertCircle, Loader } from 'lucide-react';
|
||||
import { Button, Card, Input, Select } from '../../../../components/ui';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useUpdateTenant } from '../../../../api/hooks/tenant';
|
||||
import { useCurrentTenant, useTenantActions } from '../../../../stores/tenant.store';
|
||||
import { useSettings, useUpdateSettings } from '../../../../api/hooks/settings';
|
||||
import type {
|
||||
ProcurementSettings,
|
||||
InventorySettings,
|
||||
ProductionSettings,
|
||||
SupplierSettings,
|
||||
POSSettings,
|
||||
OrderSettings,
|
||||
} from '../../../../api/types/settings';
|
||||
import ProcurementSettingsCard from '../../database/ajustes/cards/ProcurementSettingsCard';
|
||||
import InventorySettingsCard from '../../database/ajustes/cards/InventorySettingsCard';
|
||||
import ProductionSettingsCard from '../../database/ajustes/cards/ProductionSettingsCard';
|
||||
import SupplierSettingsCard from '../../database/ajustes/cards/SupplierSettingsCard';
|
||||
import POSSettingsCard from '../../database/ajustes/cards/POSSettingsCard';
|
||||
import OrderSettingsCard from '../../database/ajustes/cards/OrderSettingsCard';
|
||||
|
||||
interface BakeryConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
website: string;
|
||||
address: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
taxId: string;
|
||||
currency: string;
|
||||
timezone: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface BusinessHours {
|
||||
[key: string]: {
|
||||
open: string;
|
||||
close: string;
|
||||
closed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const BakerySettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation('settings');
|
||||
const { addToast } = useToast();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { loadUserTenants, setCurrentTenant } = useTenantActions();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: settings, isLoading: settingsLoading } = useSettings(tenantId, {
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const updateTenantMutation = useUpdateTenant();
|
||||
const updateSettingsMutation = useUpdateSettings();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('information');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const [config, setConfig] = useState<BakeryConfig>({
|
||||
name: '',
|
||||
description: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
address: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: '',
|
||||
taxId: '',
|
||||
currency: 'EUR',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es'
|
||||
});
|
||||
|
||||
const [businessHours, setBusinessHours] = useState<BusinessHours>({
|
||||
monday: { open: '07:00', close: '20:00', closed: false },
|
||||
tuesday: { open: '07:00', close: '20:00', closed: false },
|
||||
wednesday: { open: '07:00', close: '20:00', closed: false },
|
||||
thursday: { open: '07:00', close: '20:00', closed: false },
|
||||
friday: { open: '07:00', close: '20:00', closed: false },
|
||||
saturday: { open: '08:00', close: '14:00', closed: false },
|
||||
sunday: { open: '09:00', close: '13:00', closed: false }
|
||||
});
|
||||
|
||||
// Operational settings state
|
||||
const [procurementSettings, setProcurementSettings] = useState<ProcurementSettings | null>(null);
|
||||
const [inventorySettings, setInventorySettings] = useState<InventorySettings | null>(null);
|
||||
const [productionSettings, setProductionSettings] = useState<ProductionSettings | null>(null);
|
||||
const [supplierSettings, setSupplierSettings] = useState<SupplierSettings | null>(null);
|
||||
const [posSettings, setPosSettings] = useState<POSSettings | null>(null);
|
||||
const [orderSettings, setOrderSettings] = useState<OrderSettings | null>(null);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Load tenant data
|
||||
React.useEffect(() => {
|
||||
loadUserTenants();
|
||||
}, [loadUserTenants]);
|
||||
|
||||
// Update config when tenant data is loaded
|
||||
React.useEffect(() => {
|
||||
if (currentTenant) {
|
||||
setConfig({
|
||||
name: currentTenant.name || '',
|
||||
description: currentTenant.description || '',
|
||||
email: currentTenant.email || '',
|
||||
phone: currentTenant.phone || '',
|
||||
website: currentTenant.website || '',
|
||||
address: currentTenant.address || '',
|
||||
city: currentTenant.city || '',
|
||||
postalCode: currentTenant.postal_code || '',
|
||||
country: currentTenant.country || '',
|
||||
taxId: '',
|
||||
currency: 'EUR',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es'
|
||||
});
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}, [currentTenant]);
|
||||
|
||||
// Load settings into local state
|
||||
React.useEffect(() => {
|
||||
if (settings) {
|
||||
setProcurementSettings(settings.procurement_settings);
|
||||
setInventorySettings(settings.inventory_settings);
|
||||
setProductionSettings(settings.production_settings);
|
||||
setSupplierSettings(settings.supplier_settings);
|
||||
setPosSettings(settings.pos_settings);
|
||||
setOrderSettings(settings.order_settings);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: 'EUR', label: 'EUR (€)' },
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'GBP', label: 'GBP (£)' }
|
||||
];
|
||||
|
||||
const timezoneOptions = [
|
||||
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
|
||||
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
|
||||
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
|
||||
];
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'eu', label: 'Euskara' },
|
||||
{ value: 'en', label: 'English' }
|
||||
];
|
||||
|
||||
const validateConfig = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!config.name.trim()) {
|
||||
newErrors.name = t('bakery.information.fields.name') + ' ' + t('common.required');
|
||||
}
|
||||
|
||||
if (!config.email.trim()) {
|
||||
newErrors.email = t('bakery.information.fields.email') + ' ' + t('common.required');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(config.email)) {
|
||||
newErrors.email = t('common.error');
|
||||
}
|
||||
|
||||
if (!config.address.trim()) {
|
||||
newErrors.address = t('bakery.information.fields.address') + ' ' + t('common.required');
|
||||
}
|
||||
|
||||
if (!config.city.trim()) {
|
||||
newErrors.city = t('bakery.information.fields.city') + ' ' + t('common.required');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!validateConfig() || !tenantId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
email: config.email,
|
||||
phone: config.phone,
|
||||
website: config.website,
|
||||
address: config.address,
|
||||
city: config.city,
|
||||
postal_code: config.postalCode,
|
||||
country: config.country
|
||||
};
|
||||
|
||||
const updatedTenant = await updateTenantMutation.mutateAsync({
|
||||
tenantId,
|
||||
updateData
|
||||
});
|
||||
|
||||
if (updatedTenant) {
|
||||
setCurrentTenant(updatedTenant);
|
||||
await loadUserTenants();
|
||||
|
||||
const tenantStorage = localStorage.getItem('tenant-storage');
|
||||
if (tenantStorage) {
|
||||
const parsedStorage = JSON.parse(tenantStorage);
|
||||
if (parsedStorage.state && parsedStorage.state.currentTenant) {
|
||||
parsedStorage.state.currentTenant = updatedTenant;
|
||||
localStorage.setItem('tenant-storage', JSON.stringify(parsedStorage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
addToast(t('bakery.save_success'), { type: 'success' });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t('common.error');
|
||||
addToast(`${t('bakery.save_error')}: ${errorMessage}`, { type: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveOperationalSettings = async () => {
|
||||
if (!tenantId || !procurementSettings || !inventorySettings || !productionSettings ||
|
||||
!supplierSettings || !posSettings || !orderSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await updateSettingsMutation.mutateAsync({
|
||||
tenantId,
|
||||
updates: {
|
||||
procurement_settings: procurementSettings,
|
||||
inventory_settings: inventorySettings,
|
||||
production_settings: productionSettings,
|
||||
supplier_settings: supplierSettings,
|
||||
pos_settings: posSettings,
|
||||
order_settings: orderSettings,
|
||||
},
|
||||
});
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
addToast(t('bakery.save_success'), { type: 'success' });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t('common.error');
|
||||
addToast(`${t('bakery.save_error')}: ${errorMessage}`, { type: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof BakeryConfig) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setConfig(prev => ({ ...prev, [field]: e.target.value }));
|
||||
setHasUnsavedChanges(true);
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = (field: keyof BakeryConfig) => (value: string) => {
|
||||
setConfig(prev => ({ ...prev, [field]: value }));
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleHoursChange = (day: string, field: 'open' | 'close' | 'closed', value: string | boolean) => {
|
||||
setBusinessHours(prev => ({
|
||||
...prev,
|
||||
[day]: {
|
||||
...prev[day],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleOperationalSettingsChange = () => {
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (currentTenant) {
|
||||
setConfig({
|
||||
name: currentTenant.name || '',
|
||||
description: currentTenant.description || '',
|
||||
email: currentTenant.email || '',
|
||||
phone: currentTenant.phone || '',
|
||||
website: currentTenant.website || '',
|
||||
address: currentTenant.address || '',
|
||||
city: currentTenant.city || '',
|
||||
postalCode: currentTenant.postal_code || '',
|
||||
country: currentTenant.country || '',
|
||||
taxId: '',
|
||||
currency: 'EUR',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es'
|
||||
});
|
||||
}
|
||||
if (settings) {
|
||||
setProcurementSettings(settings.procurement_settings);
|
||||
setInventorySettings(settings.inventory_settings);
|
||||
setProductionSettings(settings.production_settings);
|
||||
setSupplierSettings(settings.supplier_settings);
|
||||
setPosSettings(settings.pos_settings);
|
||||
setOrderSettings(settings.order_settings);
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
};
|
||||
|
||||
if (!currentTenant || settingsLoading) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6">
|
||||
<PageHeader
|
||||
title={t('bakery.title')}
|
||||
description={t('bakery.description')}
|
||||
/>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||
<span className="ml-2 text-[var(--text-secondary)]">{t('common.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const daysOfWeek = [
|
||||
{ key: 'monday', label: t('bakery.hours.days.monday') },
|
||||
{ key: 'tuesday', label: t('bakery.hours.days.tuesday') },
|
||||
{ key: 'wednesday', label: t('bakery.hours.days.wednesday') },
|
||||
{ key: 'thursday', label: t('bakery.hours.days.thursday') },
|
||||
{ key: 'friday', label: t('bakery.hours.days.friday') },
|
||||
{ key: 'saturday', label: t('bakery.hours.days.saturday') },
|
||||
{ key: 'sunday', label: t('bakery.hours.days.sunday') }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6 pb-32">
|
||||
<PageHeader
|
||||
title={t('bakery.title')}
|
||||
description={t('bakery.description')}
|
||||
/>
|
||||
|
||||
{/* Bakery Header Card */}
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-xl flex-shrink-0">
|
||||
{config.name.charAt(0) || 'B'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-text-primary mb-1 truncate">
|
||||
{config.name || t('bakery.information.fields.name')}
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-text-secondary truncate">{config.email}</p>
|
||||
<p className="text-xs sm:text-sm text-text-tertiary truncate">{config.address}, {config.city}</p>
|
||||
</div>
|
||||
{hasUnsavedChanges && (
|
||||
<div className="flex items-center gap-2 text-sm text-yellow-600 w-full sm:w-auto">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('bakery.unsaved_changes')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
||||
<TabsTrigger value="information" className="flex-1 sm:flex-none whitespace-nowrap">
|
||||
<Store className="w-4 h-4 mr-2" />
|
||||
{t('bakery.tabs.information')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="hours" className="flex-1 sm:flex-none whitespace-nowrap">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{t('bakery.tabs.hours')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="operations" className="flex-1 sm:flex-none whitespace-nowrap">
|
||||
<SettingsIcon className="w-4 h-4 mr-2" />
|
||||
{t('bakery.tabs.operations')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab 1: Information */}
|
||||
<TabsContent value="information">
|
||||
<div className="space-y-6">
|
||||
{/* General Information */}
|
||||
<Card className="p-4 sm:p-6">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-text-primary mb-4 sm:mb-6 flex items-center">
|
||||
<Store className="w-5 h-5 mr-2" />
|
||||
{t('bakery.information.general_section')}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
|
||||
<Input
|
||||
label={t('bakery.information.fields.name')}
|
||||
value={config.name}
|
||||
onChange={handleInputChange('name')}
|
||||
error={errors.name}
|
||||
disabled={isLoading}
|
||||
placeholder={t('bakery.information.placeholders.name')}
|
||||
leftIcon={<Store className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={t('bakery.information.fields.email')}
|
||||
value={config.email}
|
||||
onChange={handleInputChange('email')}
|
||||
error={errors.email}
|
||||
disabled={isLoading}
|
||||
placeholder={t('bakery.information.placeholders.email')}
|
||||
leftIcon={<MapPin className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="tel"
|
||||
label={t('bakery.information.fields.phone')}
|
||||
value={config.phone}
|
||||
onChange={handleInputChange('phone')}
|
||||
disabled={isLoading}
|
||||
placeholder={t('bakery.information.placeholders.phone')}
|
||||
leftIcon={<Clock className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('bakery.information.fields.website')}
|
||||
value={config.website}
|
||||
onChange={handleInputChange('website')}
|
||||
disabled={isLoading}
|
||||
placeholder={t('bakery.information.placeholders.website')}
|
||||
className="md:col-span-2 xl:col-span-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-6">
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
{t('bakery.information.fields.description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={config.description}
|
||||
onChange={handleInputChange('description')}
|
||||
disabled={isLoading}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg resize-none bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)] text-sm sm:text-base"
|
||||
placeholder={t('bakery.information.placeholders.description')}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Location Information */}
|
||||
<Card className="p-4 sm:p-6">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-text-primary mb-4 sm:mb-6 flex items-center">
|
||||
<MapPin className="w-5 h-5 mr-2" />
|
||||
{t('bakery.information.location_section')}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
|
||||
<Input
|
||||
label={t('bakery.information.fields.address')}
|
||||
value={config.address}
|
||||
onChange={handleInputChange('address')}
|
||||
error={errors.address}
|
||||
disabled={isLoading}
|
||||
placeholder={t('bakery.information.placeholders.address')}
|
||||
leftIcon={<MapPin className="w-4 h-4" />}
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('bakery.information.fields.city')}
|
||||
value={config.city}
|
||||
onChange={handleInputChange('city')}
|
||||
error={errors.city}
|
||||
disabled={isLoading}
|
||||
placeholder={t('bakery.information.placeholders.city')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('bakery.information.fields.postal_code')}
|
||||
value={config.postalCode}
|
||||
onChange={handleInputChange('postalCode')}
|
||||
disabled={isLoading}
|
||||
placeholder={t('bakery.information.placeholders.postal_code')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('bakery.information.fields.country')}
|
||||
value={config.country}
|
||||
onChange={handleInputChange('country')}
|
||||
disabled={isLoading}
|
||||
placeholder={t('bakery.information.placeholders.country')}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Business Information */}
|
||||
<Card className="p-4 sm:p-6">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-text-primary mb-4 sm:mb-6">
|
||||
{t('bakery.information.business_section')}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
|
||||
<Input
|
||||
label={t('bakery.information.fields.tax_id')}
|
||||
value={config.taxId}
|
||||
onChange={handleInputChange('taxId')}
|
||||
disabled={isLoading}
|
||||
placeholder={t('bakery.information.placeholders.tax_id')}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('bakery.information.fields.currency')}
|
||||
options={currencyOptions}
|
||||
value={config.currency}
|
||||
onChange={(value) => handleSelectChange('currency')(value as string)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('bakery.information.fields.timezone')}
|
||||
options={timezoneOptions}
|
||||
value={config.timezone}
|
||||
onChange={(value) => handleSelectChange('timezone')(value as string)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('bakery.information.fields.language')}
|
||||
options={languageOptions}
|
||||
value={config.language}
|
||||
onChange={(value) => handleSelectChange('language')(value as string)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2: Business Hours */}
|
||||
<TabsContent value="hours">
|
||||
<Card className="p-4 sm:p-6">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-text-primary mb-4 sm:mb-6 flex items-center">
|
||||
<Clock className="w-5 h-5 mr-2" />
|
||||
{t('bakery.hours.title')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{daysOfWeek.map((day) => {
|
||||
const hours = businessHours[day.key];
|
||||
return (
|
||||
<div key={day.key} className="flex flex-col sm:grid sm:grid-cols-12 gap-3 sm:gap-4 p-3 sm:p-4 border border-border-primary rounded-lg">
|
||||
{/* Day Name */}
|
||||
<div className="sm:col-span-2">
|
||||
<span className="text-sm font-medium text-text-secondary">{day.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Closed Checkbox */}
|
||||
<div className="sm:col-span-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hours.closed}
|
||||
onChange={(e) => handleHoursChange(day.key, 'closed', e.target.checked)}
|
||||
disabled={isLoading}
|
||||
className="rounded border-border-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">{t('bakery.hours.closed')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Time Inputs */}
|
||||
<div className="sm:col-span-8 flex items-center gap-4 sm:gap-6">
|
||||
{!hours.closed ? (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-tertiary mb-1">
|
||||
{t('bakery.hours.open_time')}
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={hours.open}
|
||||
onChange={(e) => handleHoursChange(day.key, 'open', e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-tertiary mb-1">
|
||||
{t('bakery.hours.close_time')}
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={hours.close}
|
||||
onChange={(e) => handleHoursChange(day.key, 'close', e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-text-tertiary italic">
|
||||
{t('bakery.hours.closed_all_day')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 3: Operational Settings */}
|
||||
<TabsContent value="operations">
|
||||
<div className="space-y-6">
|
||||
{procurementSettings && (
|
||||
<ProcurementSettingsCard
|
||||
settings={procurementSettings}
|
||||
onChange={(newSettings) => {
|
||||
setProcurementSettings(newSettings);
|
||||
handleOperationalSettingsChange();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inventorySettings && (
|
||||
<InventorySettingsCard
|
||||
settings={inventorySettings}
|
||||
onChange={(newSettings) => {
|
||||
setInventorySettings(newSettings);
|
||||
handleOperationalSettingsChange();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{productionSettings && (
|
||||
<ProductionSettingsCard
|
||||
settings={productionSettings}
|
||||
onChange={(newSettings) => {
|
||||
setProductionSettings(newSettings);
|
||||
handleOperationalSettingsChange();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{supplierSettings && (
|
||||
<SupplierSettingsCard
|
||||
settings={supplierSettings}
|
||||
onChange={(newSettings) => {
|
||||
setSupplierSettings(newSettings);
|
||||
handleOperationalSettingsChange();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{posSettings && (
|
||||
<POSSettingsCard
|
||||
settings={posSettings}
|
||||
onChange={(newSettings) => {
|
||||
setPosSettings(newSettings);
|
||||
handleOperationalSettingsChange();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{orderSettings && (
|
||||
<OrderSettingsCard
|
||||
settings={orderSettings}
|
||||
onChange={(newSettings) => {
|
||||
setOrderSettings(newSettings);
|
||||
handleOperationalSettingsChange();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Floating Save Button */}
|
||||
{hasUnsavedChanges && (
|
||||
<div className="fixed bottom-6 right-4 sm:right-6 left-4 sm:left-auto z-50">
|
||||
<Card className="p-3 sm:p-4 shadow-lg border-2 border-[var(--color-primary)]">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500 flex-shrink-0" />
|
||||
<span>{t('bakery.unsaved_changes')}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDiscard}
|
||||
disabled={isLoading}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
{t('common.discard')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={activeTab === 'operations' ? handleSaveOperationalSettings : handleSaveConfig}
|
||||
isLoading={isLoading}
|
||||
loadingText={t('common.saving')}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BakerySettingsPage;
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useAuthProfile, useUpdateProfile } from '../../../../api/hooks/auth';
|
||||
import CommunicationPreferences, { type NotificationPreferences } from '../profile/CommunicationPreferences';
|
||||
|
||||
const CommunicationPreferencesPage: React.FC = () => {
|
||||
const { data: profile } = useAuthProfile();
|
||||
const updateProfileMutation = useUpdateProfile();
|
||||
|
||||
const [hasChanges, setHasChanges] = React.useState(false);
|
||||
|
||||
const handleSaveNotificationPreferences = async (preferences: NotificationPreferences) => {
|
||||
try {
|
||||
await updateProfileMutation.mutateAsync({
|
||||
language: preferences.language,
|
||||
timezone: preferences.timezone,
|
||||
notification_preferences: preferences
|
||||
});
|
||||
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
throw error; // Let the component handle the error display
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetNotificationPreferences = () => {
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Preferencias de Comunicación"
|
||||
description="Gestiona tus preferencias de notificaciones y comunicación"
|
||||
/>
|
||||
|
||||
<CommunicationPreferences
|
||||
userEmail={profile?.email || ''}
|
||||
userPhone={profile?.phone || ''}
|
||||
userLanguage={profile?.language || 'es'}
|
||||
userTimezone={profile?.timezone || 'Europe/Madrid'}
|
||||
onSave={handleSaveNotificationPreferences}
|
||||
onReset={handleResetNotificationPreferences}
|
||||
hasChanges={hasChanges}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunicationPreferencesPage;
|
||||
@@ -1,393 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { User, Mail, Phone, Lock, Globe, Clock, Camera, Save, X } from 'lucide-react';
|
||||
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';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProfileFormData {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface PasswordData {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
const PersonalInfoPage: React.FC = () => {
|
||||
const user = useAuthUser();
|
||||
const { t } = useTranslation('auth');
|
||||
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: '',
|
||||
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',
|
||||
avatar: profile.avatar || ''
|
||||
});
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const [passwordData, setPasswordData] = useState<PasswordData>({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'ca', label: 'Català' },
|
||||
{ value: 'en', label: 'English' }
|
||||
];
|
||||
|
||||
const timezoneOptions = [
|
||||
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
|
||||
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
|
||||
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
|
||||
];
|
||||
|
||||
const validateProfile = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!profileData.first_name.trim()) {
|
||||
newErrors.first_name = 'El nombre es requerido';
|
||||
}
|
||||
|
||||
if (!profileData.last_name.trim()) {
|
||||
newErrors.last_name = 'Los apellidos son requeridos';
|
||||
}
|
||||
|
||||
if (!profileData.email.trim()) {
|
||||
newErrors.email = 'El email es requerido';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileData.email)) {
|
||||
newErrors.email = 'Email inválido';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const validatePassword = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!passwordData.currentPassword) {
|
||||
newErrors.currentPassword = 'Contraseña actual requerida';
|
||||
}
|
||||
|
||||
if (!passwordData.newPassword) {
|
||||
newErrors.newPassword = 'Nueva contraseña requerida';
|
||||
} else if (passwordData.newPassword.length < 8) {
|
||||
newErrors.newPassword = 'Mínimo 8 caracteres';
|
||||
}
|
||||
|
||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Las contraseñas no coinciden';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!validateProfile()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await updateProfileMutation.mutateAsync(profileData);
|
||||
|
||||
setIsEditing(false);
|
||||
addToast('Perfil actualizado correctamente', 'success');
|
||||
} catch (error) {
|
||||
addToast('No se pudo actualizar tu perfil', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePasswordSubmit = async () => {
|
||||
if (!validatePassword()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await changePasswordMutation.mutateAsync({
|
||||
current_password: passwordData.currentPassword,
|
||||
new_password: passwordData.newPassword,
|
||||
confirm_password: passwordData.confirmPassword
|
||||
});
|
||||
|
||||
setShowPasswordForm(false);
|
||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
addToast('Contraseña actualizada correctamente', 'success');
|
||||
} catch (error) {
|
||||
addToast('No se pudo cambiar tu contraseña', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof ProfileFormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setProfileData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = (field: keyof ProfileFormData) => (value: string) => {
|
||||
setProfileData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handlePasswordChange = (field: keyof PasswordData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPasswordData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Información Personal"
|
||||
description="Gestiona tu información personal y datos de contacto"
|
||||
/>
|
||||
|
||||
{/* Profile Header */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={profile?.avatar || undefined}
|
||||
alt={profile?.full_name || `${profileData.first_name} ${profileData.last_name}` || 'Usuario'}
|
||||
name={profile?.avatar ? (profile?.full_name || `${profileData.first_name} ${profileData.last_name}`) : undefined}
|
||||
size="xl"
|
||||
className="w-20 h-20"
|
||||
/>
|
||||
<button className="absolute -bottom-1 -right-1 bg-color-primary text-white rounded-full p-2 shadow-lg hover:bg-color-primary-dark transition-colors">
|
||||
<Camera className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-1">
|
||||
{profileData.first_name} {profileData.last_name}
|
||||
</h1>
|
||||
<p className="text-text-secondary">{profileData.email}</p>
|
||||
{user?.role && (
|
||||
<p className="text-sm text-text-tertiary mt-1">
|
||||
{t(`global_roles.${user.role}`)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-sm text-text-tertiary">En línea</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
Editar Perfil
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
Cambiar Contraseña
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Profile Form */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Información Personal</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={profileData.first_name}
|
||||
onChange={handleInputChange('first_name')}
|
||||
error={errors.first_name}
|
||||
disabled={!isEditing || isLoading}
|
||||
leftIcon={<User className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Apellidos"
|
||||
value={profileData.last_name}
|
||||
onChange={handleInputChange('last_name')}
|
||||
error={errors.last_name}
|
||||
disabled={!isEditing || isLoading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label="Correo Electrónico"
|
||||
value={profileData.email}
|
||||
onChange={handleInputChange('email')}
|
||||
error={errors.email}
|
||||
disabled={!isEditing || isLoading}
|
||||
leftIcon={<Mail className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="tel"
|
||||
label="Teléfono"
|
||||
value={profileData.phone}
|
||||
onChange={handleInputChange('phone')}
|
||||
error={errors.phone}
|
||||
disabled={!isEditing || isLoading}
|
||||
placeholder="+34 600 000 000"
|
||||
leftIcon={<Phone className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Idioma"
|
||||
options={languageOptions}
|
||||
value={profileData.language}
|
||||
onChange={handleSelectChange('language')}
|
||||
disabled={!isEditing || isLoading}
|
||||
leftIcon={<Globe className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Zona Horaria"
|
||||
options={timezoneOptions}
|
||||
value={profileData.timezone}
|
||||
onChange={handleSelectChange('timezone')}
|
||||
disabled={!isEditing || isLoading}
|
||||
leftIcon={<Clock className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex gap-3 mt-6 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEditing(false)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSaveProfile}
|
||||
isLoading={isLoading}
|
||||
loadingText="Guardando..."
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Password Change Form */}
|
||||
{showPasswordForm && (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Cambiar Contraseña</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-4xl">
|
||||
<Input
|
||||
type="password"
|
||||
label="Contraseña Actual"
|
||||
value={passwordData.currentPassword}
|
||||
onChange={handlePasswordChange('currentPassword')}
|
||||
error={errors.currentPassword}
|
||||
disabled={isLoading}
|
||||
leftIcon={<Lock className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Nueva Contraseña"
|
||||
value={passwordData.newPassword}
|
||||
onChange={handlePasswordChange('newPassword')}
|
||||
error={errors.newPassword}
|
||||
disabled={isLoading}
|
||||
leftIcon={<Lock className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Confirmar Nueva Contraseña"
|
||||
value={passwordData.confirmPassword}
|
||||
onChange={handlePasswordChange('confirmPassword')}
|
||||
error={errors.confirmPassword}
|
||||
disabled={isLoading}
|
||||
leftIcon={<Lock className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-6 mt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false);
|
||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
setErrors({});
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleChangePasswordSubmit}
|
||||
isLoading={isLoading}
|
||||
loadingText="Cambiando..."
|
||||
>
|
||||
Cambiar Contraseña
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalInfoPage;
|
||||
@@ -1,547 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Shield,
|
||||
Download,
|
||||
Trash2,
|
||||
FileText,
|
||||
Cookie,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
Lock,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../../components/ui';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useAuthUser, useAuthStore, useAuthActions } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { subscriptionService } from '../../../../api';
|
||||
|
||||
export const PrivacySettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { success, error: showError } = useToast();
|
||||
const user = useAuthUser();
|
||||
const token = useAuthStore((state) => state.token);
|
||||
const { logout } = useAuthActions();
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('');
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteReason, setDeleteReason] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showExportPreview, setShowExportPreview] = useState(false);
|
||||
const [exportPreview, setExportPreview] = useState<any>(null);
|
||||
const [subscriptionStatus, setSubscriptionStatus] = useState<any>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadSubscriptionStatus = async () => {
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
if (tenantId) {
|
||||
try {
|
||||
const status = await subscriptionService.getSubscriptionStatus(tenantId);
|
||||
setSubscriptionStatus(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscription status:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSubscriptionStatus();
|
||||
}, [currentTenant, user]);
|
||||
|
||||
const handleDataExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/users/me/export', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to export data');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `my_data_export_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
success(
|
||||
t('settings:privacy.export_success', 'Your data has been exported successfully'),
|
||||
{ title: t('settings:privacy.export_complete', 'Export Complete') }
|
||||
);
|
||||
} catch (err) {
|
||||
showError(
|
||||
t('settings:privacy.export_error', 'Failed to export your data. Please try again.'),
|
||||
{ title: t('common:error', 'Error') }
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewExportPreview = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/users/me/export/summary', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch preview');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setExportPreview(data);
|
||||
setShowExportPreview(true);
|
||||
} catch (err) {
|
||||
showError(
|
||||
t('settings:privacy.preview_error', 'Failed to load preview'),
|
||||
{ title: t('common:error', 'Error') }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountDeletion = async () => {
|
||||
if (deleteConfirmEmail.toLowerCase() !== user?.email?.toLowerCase()) {
|
||||
showError(
|
||||
t('settings:privacy.email_mismatch', 'Email does not match your account email'),
|
||||
{ title: t('common:error', 'Error') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deletePassword) {
|
||||
showError(
|
||||
t('settings:privacy.password_required', 'Password is required'),
|
||||
{ title: t('common:error', 'Error') }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/users/me/delete/request', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirm_email: deleteConfirmEmail,
|
||||
password: deletePassword,
|
||||
reason: deleteReason
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to delete account');
|
||||
}
|
||||
|
||||
success(
|
||||
t('settings:privacy.delete_success', 'Your account has been deleted. You will be logged out.'),
|
||||
{ title: t('settings:privacy.account_deleted', 'Account Deleted') }
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
logout();
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
showError(
|
||||
err.message || t('settings:privacy.delete_error', 'Failed to delete account. Please try again.'),
|
||||
{ title: t('common:error', 'Error') }
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Shield className="w-8 h-8 text-primary-600" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('settings:privacy.title', 'Privacy & Data')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('settings:privacy.subtitle', 'Manage your data and privacy settings')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GDPR Rights Information */}
|
||||
<Card className="p-6 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('settings:privacy.gdpr_rights_title', 'Your Data Rights')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t(
|
||||
'settings:privacy.gdpr_rights_description',
|
||||
'Under GDPR, you have the right to access, export, and delete your personal data. These tools help you exercise those rights.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t('settings:privacy.privacy_policy', 'Privacy Policy')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<span className="text-gray-400">•</span>
|
||||
<a
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t('settings:privacy.terms', 'Terms of Service')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Cookie Preferences */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<Cookie className="w-5 h-5 text-amber-600 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('settings:privacy.cookie_preferences', 'Cookie Preferences')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{t(
|
||||
'settings:privacy.cookie_description',
|
||||
'Manage which cookies and tracking technologies we can use on your browser.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate('/cookie-preferences')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Cookie className="w-4 h-4 mr-2" />
|
||||
{t('settings:privacy.manage_cookies', 'Manage Cookies')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Data Export - Article 15 (Right to Access) & Article 20 (Data Portability) */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Download className="w-5 h-5 text-green-600 mt-1 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('settings:privacy.export_data', 'Export Your Data')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{t(
|
||||
'settings:privacy.export_description',
|
||||
'Download a copy of all your personal data in machine-readable JSON format. This includes your profile, account activity, and all data we have about you.'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mb-4">
|
||||
<strong>GDPR Rights:</strong> Article 15 (Right to Access) & Article 20 (Data Portability)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showExportPreview && exportPreview && (
|
||||
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h4 className="font-semibold text-sm text-gray-900 dark:text-white mb-3">
|
||||
{t('settings:privacy.export_preview', 'What will be exported:')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-gray-700 dark:text-gray-300">Personal data</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{exportPreview.data_counts?.active_sessions || 0} active sessions
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{exportPreview.data_counts?.consent_changes || 0} consent records
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{exportPreview.data_counts?.audit_logs || 0} audit logs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleViewExportPreview}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
{t('settings:privacy.preview_export', 'Preview')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDataExport}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting
|
||||
? t('settings:privacy.exporting', 'Exporting...')
|
||||
: t('settings:privacy.export_button', 'Export My Data')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Account Deletion - Article 17 (Right to Erasure) */}
|
||||
<Card className="p-6 border-red-200 dark:border-red-800">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 mt-1 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('settings:privacy.delete_account', 'Delete Account')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{t(
|
||||
'settings:privacy.delete_description',
|
||||
'Permanently delete your account and all associated data. This action cannot be undone.'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mb-4">
|
||||
<strong>GDPR Right:</strong> Article 17 (Right to Erasure / "Right to be Forgotten")
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-900 dark:text-red-100">
|
||||
<p className="font-semibold mb-2">
|
||||
{t('settings:privacy.delete_warning_title', 'What will be deleted:')}
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-xs">
|
||||
<li>Your account and login credentials</li>
|
||||
<li>All personal information (name, email, phone)</li>
|
||||
<li>All active sessions and devices</li>
|
||||
<li>Consent records and preferences</li>
|
||||
<li>Security logs and login history</li>
|
||||
</ul>
|
||||
<p className="mt-3 font-semibold mb-1">
|
||||
{t('settings:privacy.delete_retained_title', 'What will be retained:')}
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-xs">
|
||||
<li>Audit logs (anonymized after 1 year - legal requirement)</li>
|
||||
<li>Financial records (anonymized for 7 years - tax law)</li>
|
||||
<li>Aggregated analytics (no personal identifiers)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-300 text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('settings:privacy.delete_button', 'Delete My Account')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Additional Resources */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('settings:privacy.resources_title', 'Privacy Resources')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<a
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<FileText className="w-5 h-5 text-gray-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('settings:privacy.privacy_policy', 'Privacy Policy')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{t('settings:privacy.privacy_policy_description', 'How we handle your data')}
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-gray-400" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/cookies"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Cookie className="w-5 h-5 text-gray-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('settings:privacy.cookie_policy', 'Cookie Policy')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{t('settings:privacy.cookie_policy_description', 'About cookies we use')}
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-gray-400" />
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Delete Account Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="max-w-md w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600 flex-shrink-0" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t('settings:privacy.delete_confirm_title', 'Delete Account?')}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t(
|
||||
'settings:privacy.delete_confirm_description',
|
||||
'This action is permanent and cannot be undone. All your data will be deleted immediately.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{subscriptionStatus && subscriptionStatus.status === 'active' && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-yellow-900 dark:text-yellow-100 mb-1">
|
||||
Active Subscription Detected
|
||||
</p>
|
||||
<p className="text-yellow-800 dark:text-yellow-200 mb-2">
|
||||
You have an active {subscriptionStatus.plan} subscription. Deleting your account will:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-yellow-800 dark:text-yellow-200">
|
||||
<li>Cancel your subscription immediately</li>
|
||||
<li>No refund for remaining time</li>
|
||||
<li>Permanently delete all data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={t('settings:privacy.confirm_email_label', 'Confirm your email')}
|
||||
type="email"
|
||||
placeholder={user?.email || ''}
|
||||
value={deleteConfirmEmail}
|
||||
onChange={(e) => setDeleteConfirmEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('settings:privacy.password_label', 'Enter your password')}
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
required
|
||||
leftIcon={<Lock className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('settings:privacy.delete_reason_label', 'Reason for leaving (optional)')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
rows={3}
|
||||
placeholder={t(
|
||||
'settings:privacy.delete_reason_placeholder',
|
||||
'Help us improve by telling us why...'
|
||||
)}
|
||||
value={deleteReason}
|
||||
onChange={(e) => setDeleteReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-900 dark:text-amber-100">
|
||||
⚠️ {t('settings:privacy.delete_final_warning', 'This will permanently delete your account and all data. This action cannot be reversed.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteConfirmEmail('');
|
||||
setDeletePassword('');
|
||||
setDeleteReason('');
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t('common:actions.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAccountDeletion}
|
||||
variant="primary"
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
|
||||
disabled={isDeleting || !deleteConfirmEmail || !deletePassword}
|
||||
>
|
||||
{isDeleting
|
||||
? t('settings:privacy.deleting', 'Deleting...')
|
||||
: t('settings:privacy.delete_permanently', 'Delete Permanently')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacySettingsPage;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as PrivacySettingsPage } from './PrivacySettingsPage';
|
||||
export { default } from './PrivacySettingsPage';
|
||||
@@ -0,0 +1,799 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Lock,
|
||||
Globe,
|
||||
Clock,
|
||||
Camera,
|
||||
Save,
|
||||
X,
|
||||
Bell,
|
||||
Shield,
|
||||
Download,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
Cookie,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useAuthUser, useAuthStore, useAuthActions } from '../../../../stores/auth.store';
|
||||
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { subscriptionService } from '../../../../api';
|
||||
|
||||
// Import the communication preferences component
|
||||
import CommunicationPreferences, { type NotificationPreferences } from './CommunicationPreferences';
|
||||
|
||||
interface ProfileFormData {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface PasswordData {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
const NewProfileSettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation('settings');
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
const user = useAuthUser();
|
||||
const token = useAuthStore((state) => state.token);
|
||||
const { logout } = useAuthActions();
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
const { data: profile, isLoading: profileLoading } = useAuthProfile();
|
||||
const updateProfileMutation = useUpdateProfile();
|
||||
const changePasswordMutation = useChangePassword();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('personal');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
|
||||
// Export & Delete states
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('');
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteReason, setDeleteReason] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [subscriptionStatus, setSubscriptionStatus] = useState<any>(null);
|
||||
|
||||
const [profileData, setProfileData] = useState<ProfileFormData>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid'
|
||||
});
|
||||
|
||||
const [passwordData, setPasswordData] = useState<PasswordData>({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 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',
|
||||
avatar: profile.avatar || ''
|
||||
});
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
// Load subscription status
|
||||
React.useEffect(() => {
|
||||
const loadSubscriptionStatus = async () => {
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
if (tenantId) {
|
||||
try {
|
||||
const status = await subscriptionService.getSubscriptionStatus(tenantId);
|
||||
setSubscriptionStatus(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscription status:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSubscriptionStatus();
|
||||
}, [currentTenant, user]);
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'eu', label: 'Euskara' },
|
||||
{ value: 'en', label: 'English' }
|
||||
];
|
||||
|
||||
const timezoneOptions = [
|
||||
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
|
||||
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
|
||||
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
|
||||
];
|
||||
|
||||
const validateProfile = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!profileData.first_name.trim()) {
|
||||
newErrors.first_name = t('profile.fields.first_name') + ' ' + t('common.required');
|
||||
}
|
||||
|
||||
if (!profileData.last_name.trim()) {
|
||||
newErrors.last_name = t('profile.fields.last_name') + ' ' + t('common.required');
|
||||
}
|
||||
|
||||
if (!profileData.email.trim()) {
|
||||
newErrors.email = t('profile.fields.email') + ' ' + t('common.required');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileData.email)) {
|
||||
newErrors.email = t('common.error');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const validatePassword = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!passwordData.currentPassword) {
|
||||
newErrors.currentPassword = t('profile.password.current_password') + ' ' + t('common.required');
|
||||
}
|
||||
|
||||
if (!passwordData.newPassword) {
|
||||
newErrors.newPassword = t('profile.password.new_password') + ' ' + t('common.required');
|
||||
} else if (passwordData.newPassword.length < 8) {
|
||||
newErrors.newPassword = t('profile.password.password_requirements');
|
||||
}
|
||||
|
||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
newErrors.confirmPassword = t('common.error');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!validateProfile()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await updateProfileMutation.mutateAsync(profileData);
|
||||
|
||||
setIsEditing(false);
|
||||
addToast(t('profile.save_changes'), { type: 'success' });
|
||||
} catch (error) {
|
||||
addToast(t('common.error'), { type: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePasswordSubmit = async () => {
|
||||
if (!validatePassword()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await changePasswordMutation.mutateAsync({
|
||||
current_password: passwordData.currentPassword,
|
||||
new_password: passwordData.newPassword,
|
||||
confirm_password: passwordData.confirmPassword
|
||||
});
|
||||
|
||||
setShowPasswordForm(false);
|
||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
addToast(t('profile.password.change_success'), { type: 'success' });
|
||||
} catch (error) {
|
||||
addToast(t('profile.password.change_error'), { type: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof ProfileFormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setProfileData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = (field: keyof ProfileFormData) => (value: string) => {
|
||||
setProfileData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handlePasswordChange = (field: keyof PasswordData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPasswordData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNotificationPreferences = async (preferences: NotificationPreferences) => {
|
||||
try {
|
||||
await updateProfileMutation.mutateAsync({
|
||||
language: preferences.language,
|
||||
timezone: preferences.timezone,
|
||||
notification_preferences: preferences
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/users/me/export', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to export data');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `my_data_export_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
addToast(t('profile.privacy.export_success'), { type: 'success' });
|
||||
} catch (err) {
|
||||
addToast(t('profile.privacy.export_error'), { type: 'error' });
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountDeletion = async () => {
|
||||
if (deleteConfirmEmail.toLowerCase() !== user?.email?.toLowerCase()) {
|
||||
addToast(t('common.error'), { type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deletePassword) {
|
||||
addToast(t('common.error'), { type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/users/me/delete/request', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirm_email: deleteConfirmEmail,
|
||||
password: deletePassword,
|
||||
reason: deleteReason
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to delete account');
|
||||
}
|
||||
|
||||
addToast(t('common.success'), { type: 'success' });
|
||||
|
||||
setTimeout(() => {
|
||||
logout();
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || t('common.error'), { type: 'error' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (profileLoading || !profile) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6">
|
||||
<PageHeader
|
||||
title={t('profile.title')}
|
||||
description={t('profile.description')}
|
||||
/>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 animate-spin rounded-full border-4 border-[var(--color-primary)] border-t-transparent"></div>
|
||||
<span className="ml-2 text-[var(--text-secondary)]">{t('common.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6 pb-32">
|
||||
<PageHeader
|
||||
title={t('profile.title')}
|
||||
description={t('profile.description')}
|
||||
/>
|
||||
|
||||
{/* Profile Header */}
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={profile?.avatar || undefined}
|
||||
alt={profile?.full_name || `${profileData.first_name} ${profileData.last_name}` || 'Usuario'}
|
||||
name={profile?.avatar ? (profile?.full_name || `${profileData.first_name} ${profileData.last_name}`) : undefined}
|
||||
size="xl"
|
||||
className="w-16 h-16 sm:w-20 sm:h-20"
|
||||
/>
|
||||
<button className="absolute -bottom-1 -right-1 bg-color-primary text-white rounded-full p-2 shadow-lg hover:bg-color-primary-dark transition-colors">
|
||||
<Camera className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-text-primary mb-1 truncate">
|
||||
{profileData.first_name} {profileData.last_name}
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-text-secondary truncate">{profileData.email}</p>
|
||||
{user?.role && (
|
||||
<p className="text-xs sm:text-sm text-text-tertiary mt-1">
|
||||
{user.role}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-xs sm:text-sm text-text-tertiary">{t('profile.online')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
||||
<TabsTrigger value="personal" className="flex-1 sm:flex-none whitespace-nowrap">
|
||||
<User className="w-4 h-4 mr-2" />
|
||||
{t('profile.tabs.personal')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="flex-1 sm:flex-none whitespace-nowrap">
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
{t('profile.tabs.notifications')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="privacy" className="flex-1 sm:flex-none whitespace-nowrap">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
{t('profile.tabs.privacy')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab 1: Personal Information */}
|
||||
<TabsContent value="personal">
|
||||
<div className="space-y-6">
|
||||
{/* Profile Form */}
|
||||
<Card className="p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-semibold mb-4">{t('profile.personal_info')}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
|
||||
<Input
|
||||
label={t('profile.fields.first_name')}
|
||||
value={profileData.first_name}
|
||||
onChange={handleInputChange('first_name')}
|
||||
error={errors.first_name}
|
||||
disabled={!isEditing || isLoading}
|
||||
leftIcon={<User className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('profile.fields.last_name')}
|
||||
value={profileData.last_name}
|
||||
onChange={handleInputChange('last_name')}
|
||||
error={errors.last_name}
|
||||
disabled={!isEditing || isLoading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={t('profile.fields.email')}
|
||||
value={profileData.email}
|
||||
onChange={handleInputChange('email')}
|
||||
error={errors.email}
|
||||
disabled={!isEditing || isLoading}
|
||||
leftIcon={<Mail className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="tel"
|
||||
label={t('profile.fields.phone')}
|
||||
value={profileData.phone}
|
||||
onChange={handleInputChange('phone')}
|
||||
error={errors.phone}
|
||||
disabled={!isEditing || isLoading}
|
||||
placeholder="+34 600 000 000"
|
||||
leftIcon={<Phone className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('profile.fields.language')}
|
||||
options={languageOptions}
|
||||
value={profileData.language}
|
||||
onChange={handleSelectChange('language')}
|
||||
disabled={!isEditing || isLoading}
|
||||
leftIcon={<Globe className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('profile.fields.timezone')}
|
||||
options={timezoneOptions}
|
||||
value={profileData.timezone}
|
||||
onChange={handleSelectChange('timezone')}
|
||||
disabled={!isEditing || isLoading}
|
||||
leftIcon={<Clock className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6 pt-4 border-t flex-wrap">
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
{t('profile.edit_profile')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsEditing(false)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSaveProfile}
|
||||
isLoading={isLoading}
|
||||
loadingText={t('common.saving')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{t('profile.save_changes')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
{t('profile.change_password')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Password Change Form */}
|
||||
{showPasswordForm && (
|
||||
<Card className="p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-semibold mb-4">{t('profile.password.title')}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6 max-w-4xl">
|
||||
<Input
|
||||
type="password"
|
||||
label={t('profile.password.current_password')}
|
||||
value={passwordData.currentPassword}
|
||||
onChange={handlePasswordChange('currentPassword')}
|
||||
error={errors.currentPassword}
|
||||
disabled={isLoading}
|
||||
leftIcon={<Lock className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label={t('profile.password.new_password')}
|
||||
value={passwordData.newPassword}
|
||||
onChange={handlePasswordChange('newPassword')}
|
||||
error={errors.newPassword}
|
||||
disabled={isLoading}
|
||||
leftIcon={<Lock className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label={t('profile.password.confirm_password')}
|
||||
value={passwordData.confirmPassword}
|
||||
onChange={handlePasswordChange('confirmPassword')}
|
||||
error={errors.confirmPassword}
|
||||
disabled={isLoading}
|
||||
leftIcon={<Lock className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-6 mt-6 border-t flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false);
|
||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
setErrors({});
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleChangePasswordSubmit}
|
||||
isLoading={isLoading}
|
||||
loadingText={t('common.saving')}
|
||||
>
|
||||
{t('profile.password.change_password')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2: Notifications */}
|
||||
<TabsContent value="notifications">
|
||||
<CommunicationPreferences
|
||||
userEmail={profile?.email || ''}
|
||||
userPhone={profile?.phone || ''}
|
||||
userLanguage={profile?.language || 'es'}
|
||||
userTimezone={profile?.timezone || 'Europe/Madrid'}
|
||||
onSave={handleSaveNotificationPreferences}
|
||||
onReset={() => {}}
|
||||
hasChanges={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 3: Privacy & Data */}
|
||||
<TabsContent value="privacy">
|
||||
<div className="space-y-6">
|
||||
{/* GDPR Rights Information */}
|
||||
<Card className="p-4 sm:p-6 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('profile.privacy.gdpr_rights')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('profile.privacy.gdpr_description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t('profile.privacy.privacy_policy')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<span className="text-gray-400">•</span>
|
||||
<a
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t('profile.privacy.terms')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Cookie Preferences */}
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<Cookie className="w-5 h-5 text-amber-600 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('profile.privacy.cookie_preferences')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Gestiona tus preferencias de cookies
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate('/cookie-preferences')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Cookie className="w-4 h-4 mr-2" />
|
||||
Gestionar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Data Export */}
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Download className="w-5 h-5 text-green-600 mt-1 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('profile.privacy.export_data')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{t('profile.privacy.export_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDataExport}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{isExporting ? t('common.loading') : t('profile.privacy.export_button')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Account Deletion */}
|
||||
<Card className="p-4 sm:p-6 border-red-200 dark:border-red-800">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 mt-1 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t('profile.privacy.delete_account')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{t('profile.privacy.delete_description')}
|
||||
</p>
|
||||
<p className="text-xs text-red-600 font-semibold">
|
||||
{t('profile.privacy.delete_warning')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-300 text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20 w-full sm:w-auto"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('profile.privacy.delete_button')}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Delete Account Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="max-w-md w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t('profile.privacy.delete_account')}?
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('profile.privacy.delete_warning')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{subscriptionStatus && subscriptionStatus.status === 'active' && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-yellow-900 dark:text-yellow-100 mb-1">
|
||||
Suscripción Activa Detectada
|
||||
</p>
|
||||
<p className="text-yellow-800 dark:text-yellow-200">
|
||||
Tienes una suscripción activa que se cancelará
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Confirma tu email"
|
||||
type="email"
|
||||
placeholder={user?.email || ''}
|
||||
value={deleteConfirmEmail}
|
||||
onChange={(e) => setDeleteConfirmEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Introduce tu contraseña"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
required
|
||||
leftIcon={<Lock className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Motivo (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
|
||||
rows={3}
|
||||
placeholder="Ayúdanos a mejorar..."
|
||||
value={deleteReason}
|
||||
onChange={(e) => setDeleteReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6 flex-wrap">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteConfirmEmail('');
|
||||
setDeletePassword('');
|
||||
setDeleteReason('');
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAccountDeletion}
|
||||
variant="primary"
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
|
||||
disabled={isDeleting || !deleteConfirmEmail || !deletePassword}
|
||||
>
|
||||
{isDeleting ? t('common.loading') : t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewProfileSettingsPage;
|
||||
@@ -363,7 +363,7 @@ const ProfilePage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Mi Perfil"
|
||||
title="Ajustes"
|
||||
description="Gestiona tu información personal y preferencias de comunicación"
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-re
|
||||
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
|
||||
import { useAllUsers } from '../../../../api/hooks/user';
|
||||
import { useTeamMembers, useAddTeamMember, useAddTeamMemberWithUserCreation, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
@@ -26,15 +25,12 @@ const TeamPage: React.FC = () => {
|
||||
currentUser?.id || '',
|
||||
{ enabled: !!tenantId && !!currentUser?.id && !currentTenantAccess }
|
||||
);
|
||||
|
||||
|
||||
const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive
|
||||
const { data: allUsers = [], error: allUsersError, isLoading: allUsersLoading } = useAllUsers({
|
||||
retry: false, // Don't retry on permission errors
|
||||
staleTime: 0 // Always fresh check for permissions
|
||||
});
|
||||
|
||||
|
||||
// Mutations
|
||||
const addMemberMutation = useAddTeamMember();
|
||||
const addMemberWithUserMutation = useAddTeamMemberWithUserCreation();
|
||||
const removeMemberMutation = useRemoveTeamMember();
|
||||
const updateRoleMutation = useUpdateMemberRole();
|
||||
|
||||
@@ -46,37 +42,35 @@ const TeamPage: React.FC = () => {
|
||||
|
||||
|
||||
// Enhanced team members that includes owner information
|
||||
// Note: Backend now enriches members with user info, so we just need to ensure owner is present
|
||||
const enhancedTeamMembers = useMemo(() => {
|
||||
const members = [...teamMembers];
|
||||
|
||||
// If tenant owner is not in the members list, add them
|
||||
|
||||
// If tenant owner is not in the members list, add them as a placeholder
|
||||
if (currentTenant?.owner_id) {
|
||||
const ownerInMembers = members.find(m => m.user_id === currentTenant.owner_id);
|
||||
if (!ownerInMembers) {
|
||||
// Find owner user data
|
||||
const ownerUser = allUsers.find(u => u.id === currentTenant.owner_id);
|
||||
if (ownerUser) {
|
||||
// Add owner as a member
|
||||
members.push({
|
||||
id: `owner-${currentTenant.owner_id}`,
|
||||
tenant_id: tenantId,
|
||||
user_id: currentTenant.owner_id,
|
||||
role: TENANT_ROLES.OWNER,
|
||||
is_active: true,
|
||||
joined_at: currentTenant.created_at,
|
||||
user_email: ownerUser.email,
|
||||
user_full_name: ownerUser.full_name,
|
||||
user: ownerUser, // Add full user object for compatibility
|
||||
} as any);
|
||||
}
|
||||
// Add owner as a member with basic info
|
||||
// Note: The backend should ideally include the owner in the members list
|
||||
members.push({
|
||||
id: `owner-${currentTenant.owner_id}`,
|
||||
tenant_id: tenantId,
|
||||
user_id: currentTenant.owner_id,
|
||||
role: TENANT_ROLES.OWNER,
|
||||
is_active: true,
|
||||
joined_at: currentTenant.created_at,
|
||||
user_email: null, // Backend will enrich this
|
||||
user_full_name: null, // Backend will enrich this
|
||||
user: null,
|
||||
} as any);
|
||||
} else if (ownerInMembers.role !== TENANT_ROLES.OWNER) {
|
||||
// Update existing member to owner role
|
||||
ownerInMembers.role = TENANT_ROLES.OWNER;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return members;
|
||||
}, [teamMembers, currentTenant, allUsers, tenantId]);
|
||||
}, [teamMembers, currentTenant, tenantId]);
|
||||
|
||||
const roles = [
|
||||
{ value: 'all', label: 'Todos los Roles', count: enhancedTeamMembers.length },
|
||||
@@ -160,36 +154,54 @@ const TeamPage: React.FC = () => {
|
||||
const getMemberActions = (member: any) => {
|
||||
const actions = [];
|
||||
|
||||
// Role change actions (only for non-owners and if user can manage team)
|
||||
// Primary action - View details (always available)
|
||||
// This will be implemented in the future to show detailed member info modal
|
||||
// For now, we can comment it out as there's no modal yet
|
||||
// actions.push({
|
||||
// label: 'Ver Detalles',
|
||||
// icon: Eye,
|
||||
// priority: 'primary' as const,
|
||||
// onClick: () => {
|
||||
// // TODO: Implement member details modal
|
||||
// console.log('View member details:', member.user_id);
|
||||
// },
|
||||
// });
|
||||
|
||||
// Contextual role change actions (only for non-owners and if user can manage team)
|
||||
if (canManageTeam && member.role !== TENANT_ROLES.OWNER) {
|
||||
if (member.role !== TENANT_ROLES.ADMIN) {
|
||||
// Promote/demote to most logical next role
|
||||
if (member.role === TENANT_ROLES.VIEWER) {
|
||||
// Viewer -> Member (promote)
|
||||
actions.push({
|
||||
label: 'Hacer Admin',
|
||||
icon: Shield,
|
||||
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.ADMIN),
|
||||
priority: 'secondary' as const,
|
||||
});
|
||||
}
|
||||
|
||||
if (member.role !== TENANT_ROLES.MEMBER) {
|
||||
actions.push({
|
||||
label: 'Hacer Miembro',
|
||||
icon: Users,
|
||||
label: 'Promover a Miembro',
|
||||
icon: UserCheck,
|
||||
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.MEMBER),
|
||||
priority: 'secondary' as const,
|
||||
});
|
||||
}
|
||||
|
||||
if (member.role !== TENANT_ROLES.VIEWER) {
|
||||
actions.push({
|
||||
label: 'Hacer Observador',
|
||||
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.VIEWER),
|
||||
priority: 'tertiary' as const,
|
||||
});
|
||||
} else if (member.role === TENANT_ROLES.MEMBER) {
|
||||
// Member -> Admin (promote) or Member -> Viewer (demote)
|
||||
if (isOwner) {
|
||||
actions.push({
|
||||
label: 'Promover a Admin',
|
||||
icon: Shield,
|
||||
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.ADMIN),
|
||||
priority: 'secondary' as const,
|
||||
});
|
||||
}
|
||||
} else if (member.role === TENANT_ROLES.ADMIN) {
|
||||
// Admin -> Member (demote) - only owner can do this
|
||||
if (isOwner) {
|
||||
actions.push({
|
||||
label: 'Cambiar a Miembro',
|
||||
icon: Users,
|
||||
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.MEMBER),
|
||||
priority: 'secondary' as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove member action (only for owners)
|
||||
// Remove member action (only for owners and non-owner members)
|
||||
if (isOwner && member.role !== TENANT_ROLES.OWNER) {
|
||||
actions.push({
|
||||
label: 'Remover',
|
||||
@@ -199,7 +211,7 @@ const TeamPage: React.FC = () => {
|
||||
handleRemoveMember(member.user_id);
|
||||
}
|
||||
},
|
||||
priority: 'tertiary' as const,
|
||||
priority: 'secondary' as const,
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
@@ -216,11 +228,6 @@ const TeamPage: React.FC = () => {
|
||||
return matchesRole && matchesSearch;
|
||||
});
|
||||
|
||||
// Available users for adding (exclude current members)
|
||||
const availableUsers = allUsers.filter(u =>
|
||||
!enhancedTeamMembers.some(m => m.user_id === u.id)
|
||||
);
|
||||
|
||||
// Force reload tenant access if missing
|
||||
React.useEffect(() => {
|
||||
if (currentTenant?.id && !currentTenantAccess) {
|
||||
@@ -241,10 +248,6 @@ const TeamPage: React.FC = () => {
|
||||
directTenantAccess,
|
||||
effectiveTenantAccess,
|
||||
tenantAccess: effectiveTenantAccess?.role,
|
||||
allUsers: allUsers.length,
|
||||
allUsersError,
|
||||
allUsersLoading,
|
||||
availableUsers: availableUsers.length,
|
||||
enhancedTeamMembers: enhancedTeamMembers.length
|
||||
});
|
||||
|
||||
@@ -458,13 +461,31 @@ const TeamPage: React.FC = () => {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
await addMemberMutation.mutateAsync({
|
||||
tenantId,
|
||||
userId: userData.userId,
|
||||
role: userData.role,
|
||||
});
|
||||
// Use appropriate mutation based on whether we're creating a user
|
||||
if (userData.createUser) {
|
||||
await addMemberWithUserMutation.mutateAsync({
|
||||
tenantId,
|
||||
memberData: {
|
||||
create_user: true,
|
||||
email: userData.email!,
|
||||
full_name: userData.fullName!,
|
||||
password: userData.password!,
|
||||
phone: userData.phone,
|
||||
role: userData.role,
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid'
|
||||
}
|
||||
});
|
||||
addToast('Usuario creado y agregado exitosamente', { type: 'success' });
|
||||
} else {
|
||||
await addMemberMutation.mutateAsync({
|
||||
tenantId,
|
||||
userId: userData.userId!,
|
||||
role: userData.role,
|
||||
});
|
||||
addToast('Miembro agregado exitosamente', { type: 'success' });
|
||||
}
|
||||
|
||||
addToast('Miembro agregado exitosamente', { type: 'success' });
|
||||
setShowAddForm(false);
|
||||
setSelectedUserToAdd('');
|
||||
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
|
||||
@@ -473,11 +494,14 @@ const TeamPage: React.FC = () => {
|
||||
// Limit error already toasted above
|
||||
throw error;
|
||||
}
|
||||
addToast('Error al agregar miembro', { type: 'error' });
|
||||
addToast(
|
||||
userData.createUser ? 'Error al crear usuario' : 'Error al agregar miembro',
|
||||
{ type: 'error' }
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
availableUsers={availableUsers}
|
||||
availableUsers={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -701,49 +701,71 @@ const LandingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Grant Programs Grid */}
|
||||
<div className="mt-16 grid md:grid-cols-4 gap-6">
|
||||
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Award className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.eu_horizon', 'EU Horizon Europe')}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-2">{t('landing:sustainability.grants.eu_horizon_req', 'Requires 30% reduction')}</p>
|
||||
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{t('landing:sustainability.grants.eligible', 'Eligible')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Leaf className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.farm_to_fork', 'Farm to Fork')}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-2">{t('landing:sustainability.grants.farm_to_fork_req', 'Requires 20% reduction')}</p>
|
||||
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{t('landing:sustainability.grants.eligible', 'Eligible')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6">
|
||||
{/* LIFE Programme */}
|
||||
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
|
||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Recycle className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.circular_economy', 'Circular Economy')}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-2">{t('landing:sustainability.grants.circular_economy_req', 'Requires 15% reduction')}</p>
|
||||
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.life_circular_economy', 'LIFE Programme')}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.life_circular_economy_req', 'Requires 15% reduction')}</p>
|
||||
<p className="text-xs font-semibold text-purple-600 dark:text-purple-400 mb-2">{t('landing:sustainability.grants.life_circular_economy_funding', '€73M available')}</p>
|
||||
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{t('landing:sustainability.grants.eligible', 'Eligible')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizon Europe */}
|
||||
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Award className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.horizon_europe_cluster_6', 'Horizon Europe')}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.horizon_europe_cluster_6_req', 'Requires 20% reduction')}</p>
|
||||
<p className="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-2">{t('landing:sustainability.grants.horizon_europe_cluster_6_funding', '€880M+ annually')}</p>
|
||||
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{t('landing:sustainability.grants.eligible', 'Eligible')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fedima Grant */}
|
||||
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
|
||||
<div className="w-12 h-12 bg-orange-100 dark:bg-orange-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Leaf className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.fedima_sustainability_grant', 'Fedima Grant')}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.fedima_sustainability_grant_req', 'Requires 15% reduction')}</p>
|
||||
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400 mb-2">{t('landing:sustainability.grants.fedima_sustainability_grant_funding', '€20,000 per award')}</p>
|
||||
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{t('landing:sustainability.grants.eligible', 'Eligible')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EIT Food */}
|
||||
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Leaf className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.eit_food_retail', 'EIT Food')}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.eit_food_retail_req', 'Requires 20% reduction')}</p>
|
||||
<p className="text-xs font-semibold text-green-600 dark:text-green-400 mb-2">{t('landing:sustainability.grants.eit_food_retail_funding', '€15-45k per project')}</p>
|
||||
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{t('landing:sustainability.grants.eligible', 'Eligible')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UN SDG Certification */}
|
||||
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-amber-500 transition-all duration-300 text-center">
|
||||
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Target className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.un_sdg', 'UN SDG Certified')}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-2">{t('landing:sustainability.grants.un_sdg_req', 'Requires 50% reduction')}</p>
|
||||
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.un_sdg', 'UN SDG 12.3')}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.un_sdg_req', 'Requires 50% reduction')}</p>
|
||||
<p className="text-xs font-semibold text-amber-600 dark:text-amber-400 mb-2">Certification</p>
|
||||
<div className="inline-flex items-center gap-1 px-3 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-semibold">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{t('landing:sustainability.grants.on_track', 'On Track')}
|
||||
|
||||
Reference in New Issue
Block a user