New Frontend

This commit is contained in:
Urtzi Alfaro
2025-08-16 20:13:40 +02:00
parent 23c5f50111
commit 8914786973
35 changed files with 4223 additions and 538 deletions

View File

@@ -0,0 +1,338 @@
import React, { useState } from 'react';
import {
User,
Mail,
Phone,
Shield,
Save,
AlertCircle,
CheckCircle,
Clock
} from 'lucide-react';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
import toast from 'react-hot-toast';
interface UserProfile {
fullName: string;
email: string;
phone: string;
}
interface PasswordChange {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
interface Session {
id: string;
device: string;
location: string;
lastActive: string;
isCurrent: boolean;
}
const AccountSettingsPage: React.FC = () => {
const { user } = useSelector((state: RootState) => state.auth);
const [isLoading, setIsLoading] = useState(false);
const [profile, setProfile] = useState<UserProfile>({
fullName: user?.fullName || '',
email: user?.email || '',
phone: ''
});
const [passwordForm, setPasswordForm] = useState<PasswordChange>({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [activeSessions] = useState<Session[]>([
{
id: '1',
device: 'Chrome en Windows',
location: 'Madrid, España',
lastActive: new Date().toISOString(),
isCurrent: true
},
{
id: '2',
device: 'iPhone App',
location: 'Madrid, España',
lastActive: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
isCurrent: false
}
]);
const handleUpdateProfile = async () => {
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success('Perfil actualizado exitosamente');
} catch (error) {
toast.error('Error al actualizar el perfil');
} finally {
setIsLoading(false);
}
};
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error('Las contraseñas no coinciden');
return;
}
if (passwordForm.newPassword.length < 8) {
toast.error('La contraseña debe tener al menos 8 caracteres');
return;
}
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success('Contraseña actualizada exitosamente');
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
} catch (error) {
toast.error('Error al actualizar la contraseña');
} finally {
setIsLoading(false);
}
};
const handleTerminateSession = async (sessionId: string) => {
if (window.confirm('¿Estás seguro de que quieres cerrar esta sesión?')) {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
toast.success('Sesión cerrada exitosamente');
} catch (error) {
toast.error('Error al cerrar la sesión');
}
}
};
const handleDeleteAccount = async () => {
const confirmation = window.prompt(
'Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.\n\n' +
'Para confirmar, escribe "ELIMINAR CUENTA" exactamente como aparece:'
);
if (confirmation === 'ELIMINAR CUENTA') {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success('Cuenta eliminada exitosamente');
// In real app, this would redirect to login
} catch (error) {
toast.error('Error al eliminar la cuenta');
}
} else if (confirmation !== null) {
toast.error('Confirmación incorrecta. La cuenta no se ha eliminado.');
}
};
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="space-y-8">
{/* Profile Information */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Información Personal</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<User className="inline h-4 w-4 mr-1" />
Nombre completo
</label>
<input
type="text"
value={profile.fullName}
onChange={(e) => setProfile(prev => ({ ...prev, fullName: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Mail className="inline h-4 w-4 mr-1" />
Correo electrónico
</label>
<input
type="email"
value={profile.email}
onChange={(e) => setProfile(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Phone className="inline h-4 w-4 mr-1" />
Teléfono
</label>
<input
type="tel"
value={profile.phone}
onChange={(e) => setProfile(prev => ({ ...prev, phone: e.target.value }))}
placeholder="+34 600 000 000"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div className="flex justify-end mt-6">
<button
onClick={handleUpdateProfile}
disabled={isLoading}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4 mr-2" />
Guardar Cambios
</button>
</div>
</div>
{/* Security Settings */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">
<Shield className="inline h-5 w-5 mr-2" />
Seguridad
</h3>
{/* Change Password */}
<div className="space-y-4 mb-8">
<h4 className="font-medium text-gray-900">Cambiar Contraseña</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contraseña actual
</label>
<input
type="password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nueva contraseña
</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirmar contraseña
</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"
/>
</div>
</div>
<button
onClick={handleChangePassword}
disabled={isLoading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Actualizar Contraseña
</button>
</div>
{/* Active Sessions */}
<div className="border-t pt-6">
<h4 className="font-medium text-gray-900 mb-4">Sesiones Activas</h4>
<div className="space-y-3">
{activeSessions.map((session) => (
<div key={session.id} className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div className="flex items-center">
<div className="h-10 w-10 bg-gray-100 rounded-full flex items-center justify-center mr-3">
<Shield className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="font-medium text-gray-900">{session.device}</div>
<div className="text-sm text-gray-500">{session.location}</div>
<div className="flex items-center text-xs mt-1">
{session.isCurrent ? (
<>
<CheckCircle className="h-3 w-3 text-green-600 mr-1" />
<span className="text-green-600">Sesión actual</span>
</>
) : (
<>
<Clock className="h-3 w-3 text-gray-400 mr-1" />
<span className="text-gray-500">
Último acceso: {new Date(session.lastActive).toLocaleDateString()}
</span>
</>
)}
</div>
</div>
</div>
{!session.isCurrent && (
<button
onClick={() => handleTerminateSession(session.id)}
className="text-red-600 hover:text-red-700 text-sm font-medium"
>
Cerrar sesión
</button>
)}
</div>
))}
</div>
</div>
</div>
{/* Danger Zone */}
<div className="bg-white rounded-xl shadow-sm border border-red-200 p-6">
<h3 className="text-lg font-semibold text-red-600 mb-4">
<AlertCircle className="inline h-5 w-5 mr-2" />
Zona Peligrosa
</h3>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="font-medium text-red-900 mb-2">Eliminar Cuenta</h4>
<p className="text-red-800 text-sm mb-4">
Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.
No se puede deshacer.
</p>
<button
onClick={handleDeleteAccount}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
Eliminar Cuenta
</button>
</div>
</div>
</div>
</div>
);
};
export default AccountSettingsPage;

View File

@@ -0,0 +1,421 @@
import React, { useState, useEffect } from 'react';
import {
Plus,
Building,
MapPin,
Clock,
Users,
MoreVertical,
Edit,
Trash2,
Settings,
TrendingUp
} from 'lucide-react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../../store';
import { setCurrentTenant } from '../../store/slices/tenantSlice';
import { useTenant } from '../../api/hooks/useTenant';
import toast from 'react-hot-toast';
interface BakeryFormData {
name: string;
address: string;
business_type: 'individual' | 'central_workshop';
coordinates?: {
lat: number;
lng: number;
};
products: string[];
settings?: {
operating_hours?: {
open: string;
close: string;
};
operating_days?: number[];
timezone?: string;
currency?: string;
};
}
const BakeriesManagementPage: React.FC = () => {
const dispatch = useDispatch();
const { currentTenant } = useSelector((state: RootState) => state.tenant);
const {
tenants,
getUserTenants,
createTenant,
updateTenant,
getTenantStats,
isLoading,
error
} = useTenant();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingTenant, setEditingTenant] = useState<any>(null);
const [formData, setFormData] = useState<BakeryFormData>({
name: '',
address: '',
business_type: 'individual',
products: ['Pan', 'Croissants', 'Magdalenas'],
settings: {
operating_hours: { open: '07:00', close: '20:00' },
operating_days: [1, 2, 3, 4, 5, 6],
timezone: 'Europe/Madrid',
currency: 'EUR'
}
});
const [tenantStats, setTenantStats] = useState<any>({});
useEffect(() => {
getUserTenants();
}, [getUserTenants]);
useEffect(() => {
// Load stats for each tenant
tenants.forEach(async (tenant) => {
try {
const stats = await getTenantStats(tenant.id);
setTenantStats(prev => ({ ...prev, [tenant.id]: stats }));
} catch (error) {
console.error(`Failed to load stats for tenant ${tenant.id}:`, error);
}
});
}, [tenants, getTenantStats]);
const handleCreateBakery = async () => {
try {
const newTenant = await createTenant(formData);
toast.success('Panadería creada exitosamente');
setShowCreateModal(false);
resetForm();
} catch (error) {
toast.error('Error al crear la panadería');
}
};
const handleUpdateBakery = async () => {
if (!editingTenant) return;
try {
await updateTenant(editingTenant.id, formData);
toast.success('Panadería actualizada exitosamente');
setEditingTenant(null);
resetForm();
} catch (error) {
toast.error('Error al actualizar la panadería');
}
};
const handleSwitchTenant = (tenant: any) => {
dispatch(setCurrentTenant(tenant));
localStorage.setItem('selectedTenantId', tenant.id);
toast.success(`Cambiado a ${tenant.name}`);
};
const resetForm = () => {
setFormData({
name: '',
address: '',
business_type: 'individual',
products: ['Pan', 'Croissants', 'Magdalenas'],
settings: {
operating_hours: { open: '07:00', close: '20:00' },
operating_days: [1, 2, 3, 4, 5, 6],
timezone: 'Europe/Madrid',
currency: 'EUR'
}
});
};
const openEditModal = (tenant: any) => {
setEditingTenant(tenant);
setFormData({
name: tenant.name,
address: tenant.address,
business_type: tenant.business_type,
products: tenant.products || ['Pan', 'Croissants', 'Magdalenas'],
settings: tenant.settings || {
operating_hours: { open: '07:00', close: '20:00' },
operating_days: [1, 2, 3, 4, 5, 6],
timezone: 'Europe/Madrid',
currency: 'EUR'
}
});
};
const getBakeryTypeInfo = (type: string) => {
return type === 'individual'
? { label: 'Panadería Individual', color: 'bg-blue-100 text-blue-800' }
: { label: 'Obrador Central', color: 'bg-purple-100 text-purple-800' };
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Cargando panaderías...</p>
</div>
</div>
);
}
return (
<div className="p-6 max-w-6xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Panaderías</h1>
<p className="text-gray-600 mt-1">
Administra todas tus panaderías y puntos de venta
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
Nueva Panadería
</button>
</div>
{/* Bakeries Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tenants.map((tenant) => {
const typeInfo = getBakeryTypeInfo(tenant.business_type);
const stats = tenantStats[tenant.id];
const isActive = currentTenant?.id === tenant.id;
return (
<div
key={tenant.id}
className={`bg-white rounded-xl shadow-sm border-2 p-6 transition-all hover:shadow-md ${
isActive ? 'border-primary-500 ring-2 ring-primary-100' : 'border-gray-200'
}`}
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center">
<Building className="h-5 w-5 text-gray-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900 truncate">
{tenant.name}
</h3>
{isActive && (
<span className="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
Activa
</span>
)}
</div>
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium mt-2 ${typeInfo.color}`}>
{typeInfo.label}
</span>
</div>
<div className="relative">
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
<MoreVertical className="h-4 w-4" />
</button>
</div>
</div>
{/* Address */}
<div className="flex items-center text-gray-600 mb-4">
<MapPin className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-sm truncate">{tenant.address}</span>
</div>
{/* Operating Hours */}
{tenant.settings?.operating_hours && (
<div className="flex items-center text-gray-600 mb-4">
<Clock className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-sm">
{tenant.settings.operating_hours.open} - {tenant.settings.operating_hours.close}
</span>
</div>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 gap-4 mb-4 pt-4 border-t border-gray-100">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{stats.total_sales || 0}
</div>
<div className="text-xs text-gray-500">Ventas (mes)</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{stats.active_users || 0}
</div>
<div className="text-xs text-gray-500">Usuarios</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex space-x-2">
{!isActive && (
<button
onClick={() => handleSwitchTenant(tenant)}
className="flex-1 px-3 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 transition-colors"
>
Activar
</button>
)}
<button
onClick={() => openEditModal(tenant)}
className="flex-1 px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-lg hover:bg-gray-200 transition-colors"
>
Editar
</button>
</div>
</div>
);
})}
</div>
{/* Create/Edit Modal */}
{(showCreateModal || editingTenant) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-2xl mx-4 max-h-screen overflow-y-auto">
<h3 className="text-lg font-medium text-gray-900 mb-6">
{editingTenant ? 'Editar Panadería' : 'Nueva Panadería'}
</h3>
<div className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Mi Panadería"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de negocio
</label>
<select
value={formData.business_type}
onChange={(e) => setFormData(prev => ({ ...prev, business_type: e.target.value as any }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="individual">Panadería Individual</option>
<option value="central_workshop">Obrador Central</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dirección
</label>
<input
type="text"
value={formData.address}
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
placeholder="Calle Mayor, 123, Madrid"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Operating Hours */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Horarios de operación
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Apertura</label>
<input
type="time"
value={formData.settings?.operating_hours?.open || '07:00'}
onChange={(e) => setFormData(prev => ({
...prev,
settings: {
...prev.settings,
operating_hours: {
...prev.settings?.operating_hours,
open: e.target.value
}
}
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Cierre</label>
<input
type="time"
value={formData.settings?.operating_hours?.close || '20:00'}
onChange={(e) => setFormData(prev => ({
...prev,
settings: {
...prev.settings,
operating_hours: {
...prev.settings?.operating_hours,
close: e.target.value
}
}
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
</div>
{/* Products */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Productos
</label>
<div className="flex flex-wrap gap-2">
{formData.products.map((product, index) => (
<span
key={index}
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 text-primary-800"
>
{product}
</span>
))}
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-8">
<button
onClick={() => {
setShowCreateModal(false);
setEditingTenant(null);
resetForm();
}}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Cancelar
</button>
<button
onClick={editingTenant ? handleUpdateBakery : handleCreateBakery}
disabled={!formData.name || !formData.address}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingTenant ? 'Actualizar' : 'Crear'} Panadería
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default BakeriesManagementPage;

View File

@@ -0,0 +1,402 @@
import React, { useState } from 'react';
import {
Globe,
Clock,
DollarSign,
MapPin,
Save,
ChevronRight,
Mail,
Smartphone
} from 'lucide-react';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
import toast from 'react-hot-toast';
interface GeneralSettings {
language: string;
timezone: string;
currency: string;
bakeryName: string;
bakeryAddress: string;
businessType: string;
operatingHours: {
open: string;
close: string;
};
operatingDays: number[];
}
interface NotificationSettings {
emailNotifications: boolean;
smsNotifications: boolean;
dailyReports: boolean;
weeklyReports: boolean;
forecastAlerts: boolean;
stockAlerts: boolean;
orderReminders: boolean;
}
const GeneralSettingsPage: React.FC = () => {
const { user } = useSelector((state: RootState) => state.auth);
const { currentTenant } = useSelector((state: RootState) => state.tenant);
const [isLoading, setIsLoading] = useState(false);
const [settings, setSettings] = useState<GeneralSettings>({
language: 'es',
timezone: 'Europe/Madrid',
currency: 'EUR',
bakeryName: currentTenant?.name || 'Mi Panadería',
bakeryAddress: currentTenant?.address || '',
businessType: currentTenant?.business_type || 'individual',
operatingHours: {
open: currentTenant?.settings?.operating_hours?.open || '07:00',
close: currentTenant?.settings?.operating_hours?.close || '20:00'
},
operatingDays: currentTenant?.settings?.operating_days || [1, 2, 3, 4, 5, 6]
});
const [notifications, setNotifications] = useState<NotificationSettings>({
emailNotifications: true,
smsNotifications: false,
dailyReports: true,
weeklyReports: true,
forecastAlerts: true,
stockAlerts: true,
orderReminders: true
});
const handleSaveSettings = async () => {
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success('Configuración guardada exitosamente');
} catch (error) {
toast.error('Error al guardar la configuración');
} finally {
setIsLoading(false);
}
};
const dayLabels = ['L', 'M', 'X', 'J', 'V', 'S', 'D'];
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="space-y-8">
{/* Business Information */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Información del Negocio</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre de la panadería
</label>
<input
type="text"
value={settings.bakeryName}
onChange={(e) => setSettings(prev => ({ ...prev, bakeryName: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de negocio
</label>
<select
value={settings.businessType}
onChange={(e) => setSettings(prev => ({ ...prev, businessType: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="individual">Panadería Individual</option>
<option value="central_workshop">Obrador Central</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dirección
</label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
type="text"
value={settings.bakeryAddress}
onChange={(e) => setSettings(prev => ({ ...prev, bakeryAddress: e.target.value }))}
placeholder="Calle Mayor, 123, Madrid"
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
</div>
</div>
{/* Operating Hours */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Horarios de Operación</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hora de apertura
</label>
<input
type="time"
value={settings.operatingHours.open}
onChange={(e) => setSettings(prev => ({
...prev,
operatingHours: { ...prev.operatingHours, open: e.target.value }
}))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hora de cierre
</label>
<input
type="time"
value={settings.operatingHours.close}
onChange={(e) => setSettings(prev => ({
...prev,
operatingHours: { ...prev.operatingHours, close: e.target.value }
}))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Días de operación
</label>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-7">
{dayLabels.map((day, index) => (
<label key={day} className="flex items-center justify-center">
<input
type="checkbox"
checked={settings.operatingDays.includes(index + 1)}
onChange={(e) => {
const dayNum = index + 1;
setSettings(prev => ({
...prev,
operatingDays: e.target.checked
? [...prev.operatingDays, dayNum]
: prev.operatingDays.filter(d => d !== dayNum)
}));
}}
className="sr-only peer"
/>
<div className="w-10 h-10 bg-gray-200 peer-checked:bg-primary-500 peer-checked:text-white rounded-lg flex items-center justify-center font-medium text-sm cursor-pointer transition-colors">
{day}
</div>
</label>
))}
</div>
</div>
</div>
</div>
{/* Regional Settings */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Configuración Regional</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Globe className="inline h-4 w-4 mr-1" />
Idioma
</label>
<select
value={settings.language}
onChange={(e) => setSettings(prev => ({ ...prev, language: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="es">Español</option>
<option value="en">English</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Clock className="inline h-4 w-4 mr-1" />
Zona horaria
</label>
<select
value={settings.timezone}
onChange={(e) => setSettings(prev => ({ ...prev, timezone: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="Europe/Madrid">Europa/Madrid (CET)</option>
<option value="Europe/London">Europa/Londres (GMT)</option>
<option value="America/New_York">América/Nueva York (EST)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<DollarSign className="inline h-4 w-4 mr-1" />
Moneda
</label>
<select
value={settings.currency}
onChange={(e) => setSettings(prev => ({ ...prev, currency: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="EUR">Euro ()</option>
<option value="USD">Dólar americano ($)</option>
<option value="GBP">Libra esterlina (£)</option>
</select>
</div>
</div>
</div>
{/* Notification Settings */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Notificaciones</h3>
{/* Notification Channels */}
<div className="space-y-4 mb-6">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-center">
<Mail className="h-5 w-5 text-gray-600 mr-3" />
<div>
<div className="font-medium text-gray-900">Notificaciones por Email</div>
<div className="text-sm text-gray-500">Recibe alertas y reportes por correo</div>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications.emailNotifications}
onChange={(e) => setNotifications(prev => ({
...prev,
emailNotifications: e.target.checked
}))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-center">
<Smartphone className="h-5 w-5 text-gray-600 mr-3" />
<div>
<div className="font-medium text-gray-900">Notificaciones SMS</div>
<div className="text-sm text-gray-500">Alertas urgentes por mensaje de texto</div>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications.smsNotifications}
onChange={(e) => setNotifications(prev => ({
...prev,
smsNotifications: e.target.checked
}))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
</div>
{/* Notification Types */}
<div className="space-y-3">
<h4 className="font-medium text-gray-900">Tipos de Notificación</h4>
{[
{ key: 'dailyReports', label: 'Reportes Diarios', desc: 'Resumen diario de ventas y predicciones' },
{ key: 'weeklyReports', label: 'Reportes Semanales', desc: 'Análisis semanal de rendimiento' },
{ key: 'forecastAlerts', label: 'Alertas de Predicción', desc: 'Cambios significativos en demanda' },
{ key: 'stockAlerts', label: 'Alertas de Stock', desc: 'Inventario bajo o próximos vencimientos' },
{ key: 'orderReminders', label: 'Recordatorios de Pedidos', desc: 'Próximas entregas y fechas límite' }
].map((item) => (
<div key={item.key} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg">
<div>
<div className="font-medium text-gray-900">{item.label}</div>
<div className="text-sm text-gray-500">{item.desc}</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications[item.key as keyof NotificationSettings] as boolean}
onChange={(e) => setNotifications(prev => ({
...prev,
[item.key]: e.target.checked
}))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
))}
</div>
</div>
{/* Data Export */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Exportar Datos</h3>
<div className="space-y-3">
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">Exportar todas las predicciones</div>
<div className="text-sm text-gray-500">Descargar historial completo en CSV</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400" />
</div>
</button>
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">Exportar datos de ventas</div>
<div className="text-sm text-gray-500">Historial de ventas y análisis</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400" />
</div>
</button>
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">Exportar configuración</div>
<div className="text-sm text-gray-500">Respaldo de toda la configuración</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400" />
</div>
</button>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
onClick={handleSaveSettings}
disabled={isLoading}
className="inline-flex items-center px-6 py-3 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Guardando...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Guardar Cambios
</>
)}
</button>
</div>
</div>
</div>
);
};
export default GeneralSettingsPage;

View File

@@ -0,0 +1,326 @@
import React, { useState, useEffect } from 'react';
import {
UserPlus,
Mail,
Shield,
MoreVertical,
Trash2,
Edit,
Send,
User,
Crown,
Briefcase,
CheckCircle,
Clock,
AlertCircle
} from 'lucide-react';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
import { useTenant } from '../../api/hooks/useTenant';
import toast from 'react-hot-toast';
interface UserMember {
id: string;
user_id: string;
role: 'owner' | 'admin' | 'manager' | 'worker';
status: 'active' | 'pending' | 'inactive';
user: {
id: string;
email: string;
full_name: string;
last_active?: string;
};
joined_at: string;
}
const UsersManagementPage: React.FC = () => {
const { currentTenant } = useSelector((state: RootState) => state.tenant);
const { user: currentUser } = useSelector((state: RootState) => state.auth);
const {
members,
getTenantMembers,
inviteUser,
removeMember,
updateMemberRole,
isLoading,
error
} = useTenant();
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteForm, setInviteForm] = useState({
email: '',
role: 'worker' as const,
message: ''
});
const [selectedMember, setSelectedMember] = useState<UserMember | null>(null);
const [showRoleModal, setShowRoleModal] = useState(false);
useEffect(() => {
if (currentTenant) {
getTenantMembers(currentTenant.id);
}
}, [currentTenant, getTenantMembers]);
const handleInviteUser = async () => {
if (!currentTenant || !inviteForm.email) return;
try {
await inviteUser(currentTenant.id, {
email: inviteForm.email,
role: inviteForm.role,
message: inviteForm.message
});
toast.success('Invitación enviada exitosamente');
setShowInviteModal(false);
setInviteForm({ email: '', role: 'worker', message: '' });
} catch (error) {
toast.error('Error al enviar la invitación');
}
};
const handleRemoveMember = async (member: UserMember) => {
if (!currentTenant) return;
if (window.confirm(`¿Estás seguro de que quieres eliminar a ${member.user.full_name}?`)) {
try {
await removeMember(currentTenant.id, member.user_id);
toast.success('Usuario eliminado exitosamente');
} catch (error) {
toast.error('Error al eliminar usuario');
}
}
};
const handleUpdateRole = async (newRole: string) => {
if (!currentTenant || !selectedMember) return;
try {
await updateMemberRole(currentTenant.id, selectedMember.user_id, newRole);
toast.success('Rol actualizado exitosamente');
setShowRoleModal(false);
setSelectedMember(null);
} catch (error) {
toast.error('Error al actualizar el rol');
}
};
const getRoleInfo = (role: string) => {
const roleMap = {
owner: { label: 'Propietario', icon: Crown, color: 'text-yellow-600 bg-yellow-100' },
admin: { label: 'Administrador', icon: Shield, color: 'text-red-600 bg-red-100' },
manager: { label: 'Gerente', icon: Briefcase, color: 'text-blue-600 bg-blue-100' },
worker: { label: 'Empleado', icon: User, color: 'text-green-600 bg-green-100' }
};
return roleMap[role as keyof typeof roleMap] || roleMap.worker;
};
const getStatusInfo = (status: string) => {
const statusMap = {
active: { label: 'Activo', icon: CheckCircle, color: 'text-green-600' },
pending: { label: 'Pendiente', icon: Clock, color: 'text-yellow-600' },
inactive: { label: 'Inactivo', icon: AlertCircle, color: 'text-gray-600' }
};
return statusMap[status as keyof typeof statusMap] || statusMap.inactive;
};
const canManageUser = (member: UserMember): boolean => {
// Owners can manage everyone except other owners
// Admins can manage managers and workers
// Managers and workers can't manage anyone
if (currentUser?.role === 'owner') {
return member.role !== 'owner' || member.user_id === currentUser.id;
}
if (currentUser?.role === 'admin') {
return ['manager', 'worker'].includes(member.role);
}
return false;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Cargando usuarios...</p>
</div>
</div>
);
}
return (
<div className="p-6 max-w-6xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Usuarios</h1>
<p className="text-gray-600 mt-1">
Administra los miembros de tu equipo en {currentTenant?.name}
</p>
</div>
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<UserPlus className="h-4 w-4 mr-2" />
Invitar Usuario
</button>
</div>
{/* Users List */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">
Miembros del Equipo ({members.length})
</h3>
</div>
<div className="divide-y divide-gray-200">
{members.map((member) => {
const roleInfo = getRoleInfo(member.role);
const statusInfo = getStatusInfo(member.status);
const RoleIcon = roleInfo.icon;
const StatusIcon = statusInfo.icon;
return (
<div key={member.id} className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center flex-1 min-w-0">
{/* Avatar */}
<div className="h-10 w-10 bg-primary-100 rounded-full flex items-center justify-center">
<User className="h-5 w-5 text-primary-600" />
</div>
{/* User Info */}
<div className="ml-4 flex-1 min-w-0">
<div className="flex items-center">
<h4 className="text-sm font-medium text-gray-900 truncate">
{member.user.full_name}
</h4>
{member.user_id === currentUser?.id && (
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate">
{member.user.email}
</p>
<div className="flex items-center mt-1 space-x-4">
<div className="flex items-center">
<StatusIcon className={`h-3 w-3 mr-1 ${statusInfo.color}`} />
<span className={`text-xs ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{member.user.last_active && (
<span className="text-xs text-gray-400">
Último acceso: {new Date(member.user.last_active).toLocaleDateString()}
</span>
)}
</div>
</div>
</div>
{/* Role Badge */}
<div className="flex items-center ml-4">
<div className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${roleInfo.color}`}>
<RoleIcon className="h-3 w-3 mr-1" />
{roleInfo.label}
</div>
</div>
{/* Actions */}
{canManageUser(member) && (
<div className="flex items-center ml-4">
<div className="relative">
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
<MoreVertical className="h-4 w-4" />
</button>
{/* Dropdown would go here */}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Invite User Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Invitar Nuevo Usuario
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Correo electrónico
</label>
<input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm(prev => ({ ...prev, email: e.target.value }))}
placeholder="usuario@ejemplo.com"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rol
</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm(prev => ({ ...prev, role: e.target.value as any }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="worker">Empleado</option>
<option value="manager">Gerente</option>
{currentUser?.role === 'owner' && <option value="admin">Administrador</option>}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Mensaje personal (opcional)
</label>
<textarea
value={inviteForm.message}
onChange={(e) => setInviteForm(prev => ({ ...prev, message: e.target.value }))}
placeholder="Mensaje de bienvenida..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => setShowInviteModal(false)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Cancelar
</button>
<button
onClick={handleInviteUser}
disabled={!inviteForm.email}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="h-4 w-4 mr-2" />
Enviar Invitación
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default UsersManagementPage;