Add frontend pages imporvements
This commit is contained in:
@@ -23,6 +23,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
userId: '',
|
userId: '',
|
||||||
|
userEmail: '', // Add email field for manual input
|
||||||
role: TENANT_ROLES.MEMBER
|
role: TENANT_ROLES.MEMBER
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
|||||||
// Map field positions to form data fields
|
// Map field positions to form data fields
|
||||||
const fieldMappings = [
|
const fieldMappings = [
|
||||||
// Basic Information section
|
// Basic Information section
|
||||||
['userId', 'role']
|
['userId', 'userEmail', 'role']
|
||||||
];
|
];
|
||||||
|
|
||||||
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData;
|
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData;
|
||||||
@@ -46,9 +47,9 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// Validation
|
// Validation - need either userId OR userEmail
|
||||||
if (!formData.userId) {
|
if (!formData.userId && !formData.userEmail) {
|
||||||
alert('Por favor selecciona un usuario');
|
alert('Por favor selecciona un usuario o ingresa un email');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
if (onAddMember) {
|
if (onAddMember) {
|
||||||
await onAddMember({
|
await onAddMember({
|
||||||
userId: formData.userId,
|
userId: formData.userId || formData.userEmail, // Use email as userId if no userId selected
|
||||||
role: formData.role
|
role: formData.role
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -69,6 +70,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
|||||||
// Reset form
|
// Reset form
|
||||||
setFormData({
|
setFormData({
|
||||||
userId: '',
|
userId: '',
|
||||||
|
userEmail: '',
|
||||||
role: TENANT_ROLES.MEMBER
|
role: TENANT_ROLES.MEMBER
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
|||||||
// Reset form to initial values
|
// Reset form to initial values
|
||||||
setFormData({
|
setFormData({
|
||||||
userId: '',
|
userId: '',
|
||||||
|
userEmail: '',
|
||||||
role: TENANT_ROLES.MEMBER
|
role: TENANT_ROLES.MEMBER
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
@@ -104,10 +107,12 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
|||||||
{ label: 'Observador - Solo lectura', value: TENANT_ROLES.VIEWER }
|
{ label: 'Observador - Solo lectura', value: TENANT_ROLES.VIEWER }
|
||||||
];
|
];
|
||||||
|
|
||||||
const userOptions = availableUsers.map(user => ({
|
const userOptions = availableUsers.length > 0
|
||||||
label: `${user.full_name} (${user.email})`,
|
? availableUsers.map(user => ({
|
||||||
value: user.id
|
label: `${user.full_name} (${user.email})`,
|
||||||
}));
|
value: user.id
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
const getRoleDescription = (role: string) => {
|
const getRoleDescription = (role: string) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
@@ -127,14 +132,22 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
|||||||
title: 'Información del Miembro',
|
title: 'Información del Miembro',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
...(userOptions.length > 0 ? [{
|
||||||
label: 'Usuario',
|
label: 'Usuario',
|
||||||
value: formData.userId,
|
value: formData.userId,
|
||||||
type: 'select' as const,
|
type: 'select' as const,
|
||||||
editable: true,
|
editable: true,
|
||||||
required: true,
|
required: !formData.userEmail, // Only required if email not provided
|
||||||
options: userOptions,
|
options: userOptions,
|
||||||
placeholder: 'Seleccionar usuario...'
|
placeholder: 'Seleccionar usuario...'
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
label: userOptions.length > 0 ? 'O Email del Usuario' : 'Email del Usuario',
|
||||||
|
value: formData.userEmail,
|
||||||
|
type: 'email' as const,
|
||||||
|
editable: true,
|
||||||
|
required: !formData.userId, // Only required if user not selected
|
||||||
|
placeholder: 'usuario@ejemplo.com'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Rol',
|
label: 'Rol',
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ import { Avatar } from '../../ui';
|
|||||||
import { Badge } from '../../ui';
|
import { Badge } from '../../ui';
|
||||||
import { Modal } from '../../ui';
|
import { Modal } from '../../ui';
|
||||||
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
||||||
|
import { ThemeToggle } from '../../ui/ThemeToggle';
|
||||||
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
|
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
Bell,
|
Bell,
|
||||||
Sun,
|
Settings,
|
||||||
Moon,
|
User,
|
||||||
Computer,
|
|
||||||
Settings,
|
|
||||||
User,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
X
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -116,7 +114,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [isThemeMenuOpen, setIsThemeMenuOpen] = useState(false);
|
|
||||||
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
||||||
|
|
||||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
@@ -179,7 +176,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
// Escape to close menus
|
// Escape to close menus
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setIsUserMenuOpen(false);
|
setIsUserMenuOpen(false);
|
||||||
setIsThemeMenuOpen(false);
|
|
||||||
setIsNotificationPanelOpen(false);
|
setIsNotificationPanelOpen(false);
|
||||||
if (isSearchFocused) {
|
if (isSearchFocused) {
|
||||||
searchInputRef.current?.blur();
|
searchInputRef.current?.blur();
|
||||||
@@ -198,9 +194,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
if (!target.closest('[data-user-menu]')) {
|
if (!target.closest('[data-user-menu]')) {
|
||||||
setIsUserMenuOpen(false);
|
setIsUserMenuOpen(false);
|
||||||
}
|
}
|
||||||
if (!target.closest('[data-theme-menu]')) {
|
|
||||||
setIsThemeMenuOpen(false);
|
|
||||||
}
|
|
||||||
if (!target.closest('[data-notification-panel]')) {
|
if (!target.closest('[data-notification-panel]')) {
|
||||||
setIsNotificationPanelOpen(false);
|
setIsNotificationPanelOpen(false);
|
||||||
}
|
}
|
||||||
@@ -210,13 +203,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
return () => document.removeEventListener('click', handleClickOutside);
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const themeIcons = {
|
|
||||||
light: Sun,
|
|
||||||
dark: Moon,
|
|
||||||
auto: Computer,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeIcon = themeIcons[theme] || Sun;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
@@ -352,45 +338,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
|
|
||||||
{/* Theme toggle */}
|
{/* Theme toggle */}
|
||||||
{showThemeToggle && (
|
{showThemeToggle && (
|
||||||
<div className="relative" data-theme-menu>
|
<ThemeToggle variant="button" size="md" />
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsThemeMenuOpen(!isThemeMenuOpen)}
|
|
||||||
className="w-10 h-10 p-0 flex items-center justify-center"
|
|
||||||
aria-label={`Tema actual: ${theme}`}
|
|
||||||
aria-expanded={isThemeMenuOpen}
|
|
||||||
aria-haspopup="true"
|
|
||||||
>
|
|
||||||
<ThemeIcon className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{isThemeMenuOpen && (
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-48 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-1 z-[var(--z-dropdown)]">
|
|
||||||
{[
|
|
||||||
{ key: 'light' as const, label: 'Claro', icon: Sun },
|
|
||||||
{ key: 'dark' as const, label: 'Oscuro', icon: Moon },
|
|
||||||
{ key: 'auto' as const, label: 'Sistema', icon: Computer },
|
|
||||||
].map(({ key, label, icon: Icon }) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => {
|
|
||||||
setTheme(key);
|
|
||||||
setIsThemeMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className={clsx(
|
|
||||||
'w-full px-4 py-2 text-left text-sm flex items-center gap-3',
|
|
||||||
'hover:bg-[var(--bg-secondary)] transition-colors',
|
|
||||||
theme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTenant } from '../../stores/tenant.store';
|
import { useTenant } from '../../stores/tenant.store';
|
||||||
import { useToast } from '../../hooks/ui/useToast';
|
import { useToast } from '../../hooks/ui/useToast';
|
||||||
import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react';
|
import { ChevronDown, Building2, Check, AlertCircle, Plus } from 'lucide-react';
|
||||||
|
|
||||||
interface TenantSwitcherProps {
|
interface TenantSwitcherProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -13,6 +14,7 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
showLabel = true,
|
showLabel = true,
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [dropdownPosition, setDropdownPosition] = useState<{
|
const [dropdownPosition, setDropdownPosition] = useState<{
|
||||||
top: number;
|
top: number;
|
||||||
@@ -23,7 +25,7 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
}>({ top: 0, left: 0, width: 288, isMobile: false });
|
}>({ top: 0, left: 0, width: 288, isMobile: false });
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentTenant,
|
currentTenant,
|
||||||
availableTenants,
|
availableTenants,
|
||||||
@@ -33,7 +35,7 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
loadUserTenants,
|
loadUserTenants,
|
||||||
clearError,
|
clearError,
|
||||||
} = useTenant();
|
} = useTenant();
|
||||||
|
|
||||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||||
|
|
||||||
// Load tenants on mount
|
// Load tenants on mount
|
||||||
@@ -170,6 +172,12 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
loadUserTenants();
|
loadUserTenants();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle creating new tenant
|
||||||
|
const handleCreateNewTenant = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
navigate('/app/onboarding');
|
||||||
|
};
|
||||||
|
|
||||||
// Don't render if no tenants available
|
// Don't render if no tenants available
|
||||||
if (!availableTenants || availableTenants.length === 0) {
|
if (!availableTenants || availableTenants.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -229,11 +237,8 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`border-b border-border-primary ${dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'}`}>
|
<div className={`border-b border-border-primary ${dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'}`}>
|
||||||
<h3 className={`font-semibold text-text-primary ${dropdownPosition.isMobile ? 'text-base' : 'text-sm'}`}>
|
<h3 className={`font-semibold text-text-primary ${dropdownPosition.isMobile ? 'text-base' : 'text-sm'}`}>
|
||||||
Switch Organization
|
Organizations
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`text-text-secondary ${dropdownPosition.isMobile ? 'text-sm mt-1' : 'text-xs'}`}>
|
|
||||||
Select the organization you want to work with
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error State */}
|
{/* Error State */}
|
||||||
@@ -267,21 +272,25 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 bg-color-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||||
<Building2 className="w-4 h-4 text-color-primary" />
|
tenant.id === currentTenant?.id
|
||||||
|
? 'bg-color-primary text-white'
|
||||||
|
: 'bg-color-primary/10 text-color-primary'
|
||||||
|
}`}>
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-text-primary truncate">
|
<p className="text-sm font-medium text-text-primary truncate">
|
||||||
{tenant.name}
|
{tenant.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-secondary truncate">
|
<p className="text-xs text-text-secondary truncate">
|
||||||
{tenant.business_type} • {tenant.city}
|
{tenant.city}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tenant.id === currentTenant?.id && (
|
{tenant.id === currentTenant?.id && (
|
||||||
<Check className="w-4 h-4 text-color-success flex-shrink-0 ml-2" />
|
<Check className="w-4 h-4 text-color-success flex-shrink-0 ml-2" />
|
||||||
)}
|
)}
|
||||||
@@ -294,14 +303,17 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
<div className={`border-t border-border-primary bg-bg-secondary rounded-b-lg ${
|
<div className={`border-t border-border-primary bg-bg-secondary rounded-b-lg ${
|
||||||
dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
|
dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
|
||||||
}`}>
|
}`}>
|
||||||
<p className={`text-text-secondary ${dropdownPosition.isMobile ? 'text-sm' : 'text-xs'}`}>
|
<button
|
||||||
Need to add a new organization?{' '}
|
onClick={handleCreateNewTenant}
|
||||||
<button className={`text-color-primary hover:text-color-primary-dark underline ${
|
className={`w-full flex items-center justify-center gap-2 ${
|
||||||
dropdownPosition.isMobile ? 'active:text-color-primary-dark' : ''
|
dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
|
||||||
}`}>
|
} text-color-primary hover:text-color-primary-dark hover:bg-bg-tertiary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20`}
|
||||||
Contact Support
|
>
|
||||||
</button>
|
<Plus className="w-4 h-4" />
|
||||||
</p>
|
<span className={`font-medium ${dropdownPosition.isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||||
|
Add New Organization
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { Button } from '../Button';
|
import { Button } from '../Button';
|
||||||
import { Sun, Moon, Computer } from 'lucide-react';
|
import { Sun, Moon } from 'lucide-react';
|
||||||
|
|
||||||
export interface ThemeToggleProps {
|
export interface ThemeToggleProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -29,7 +29,7 @@ export interface ThemeToggleProps {
|
|||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Multiple display variants (button, dropdown, switch)
|
* - Multiple display variants (button, dropdown, switch)
|
||||||
* - Support for light/dark/system themes
|
* - Support for light/dark themes
|
||||||
* - Configurable size and labels
|
* - Configurable size and labels
|
||||||
* - Accessible keyboard navigation
|
* - Accessible keyboard navigation
|
||||||
* - Click outside to close dropdown
|
* - Click outside to close dropdown
|
||||||
@@ -47,10 +47,11 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
|
|||||||
const themes = [
|
const themes = [
|
||||||
{ key: 'light' as const, label: 'Claro', icon: Sun },
|
{ key: 'light' as const, label: 'Claro', icon: Sun },
|
||||||
{ key: 'dark' as const, label: 'Oscuro', icon: Moon },
|
{ key: 'dark' as const, label: 'Oscuro', icon: Moon },
|
||||||
{ key: 'auto' as const, label: 'Sistema', icon: Computer },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentTheme = themes.find(t => t.key === theme) || themes[0];
|
// If theme is 'auto', use the resolved theme for display
|
||||||
|
const displayTheme = theme === 'auto' ? resolvedTheme : theme;
|
||||||
|
const currentTheme = themes.find(t => t.key === displayTheme) || themes[0];
|
||||||
const CurrentIcon = currentTheme.icon;
|
const CurrentIcon = currentTheme.icon;
|
||||||
|
|
||||||
// Size mappings
|
// Size mappings
|
||||||
@@ -93,20 +94,18 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
|
|||||||
return () => document.removeEventListener('click', handleClickOutside);
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
}, [isDropdownOpen]);
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
// Cycle through themes for button variant
|
// Toggle between light and dark for button variant
|
||||||
const handleButtonToggle = () => {
|
const handleButtonToggle = () => {
|
||||||
const currentIndex = themes.findIndex(t => t.key === theme);
|
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||||
const nextIndex = (currentIndex + 1) % themes.length;
|
|
||||||
setTheme(themes[nextIndex].key);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle theme selection
|
// Handle theme selection
|
||||||
const handleThemeSelect = (themeKey: 'light' | 'dark' | 'auto') => {
|
const handleThemeSelect = (themeKey: 'light' | 'dark') => {
|
||||||
setTheme(themeKey);
|
setTheme(themeKey);
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Button variant - cycles through themes
|
// Button variant - toggles between light and dark
|
||||||
if (variant === 'button') {
|
if (variant === 'button') {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -209,14 +208,14 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
|
|||||||
'w-full px-4 py-2 text-left text-sm flex items-center gap-3',
|
'w-full px-4 py-2 text-left text-sm flex items-center gap-3',
|
||||||
'hover:bg-[var(--bg-secondary)] transition-colors',
|
'hover:bg-[var(--bg-secondary)] transition-colors',
|
||||||
'focus:bg-[var(--bg-secondary)] focus:outline-none',
|
'focus:bg-[var(--bg-secondary)] focus:outline-none',
|
||||||
theme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]'
|
displayTheme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]'
|
||||||
)}
|
)}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
aria-label={`Cambiar a tema ${label.toLowerCase()}`}
|
aria-label={`Cambiar a tema ${label.toLowerCase()}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
{label}
|
{label}
|
||||||
{theme === key && (
|
{displayTheme === key && (
|
||||||
<div className="ml-auto w-2 h-2 bg-[var(--color-primary)] rounded-full" />
|
<div className="ml-auto w-2 h-2 bg-[var(--color-primary)] rounded-full" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
5
frontend/src/hooks/index.ts
Normal file
5
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Export commonly used hooks
|
||||||
|
export { default as useLocalStorage } from './useLocalStorage';
|
||||||
|
export { default as useDebounce } from './useDebounce';
|
||||||
|
export { default as useSubscription } from './useSubscription';
|
||||||
|
export { useTenantId, useTenantInfo, useRequiredTenant } from './useTenantId';
|
||||||
49
frontend/src/hooks/useTenantId.ts
Normal file
49
frontend/src/hooks/useTenantId.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Custom hook to consistently get tenant ID from tenant store
|
||||||
|
* Provides a standardized way to access tenant ID across the application
|
||||||
|
*/
|
||||||
|
import { useCurrentTenant } from '../stores/tenant.store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get the current tenant ID
|
||||||
|
* @returns {string} The current tenant ID, or empty string if not available
|
||||||
|
*/
|
||||||
|
export const useTenantId = (): string => {
|
||||||
|
const currentTenant = useCurrentTenant();
|
||||||
|
return currentTenant?.id || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get both tenant and tenant ID
|
||||||
|
* @returns {object} Object containing currentTenant and tenantId
|
||||||
|
*/
|
||||||
|
export const useTenantInfo = () => {
|
||||||
|
const currentTenant = useCurrentTenant();
|
||||||
|
const tenantId = currentTenant?.id || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentTenant,
|
||||||
|
tenantId,
|
||||||
|
hasTenant: !!currentTenant,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to ensure tenant is available before proceeding
|
||||||
|
* Useful for components that absolutely require a tenant to function
|
||||||
|
* @returns {object} Object with tenant info and loading state
|
||||||
|
*/
|
||||||
|
export const useRequiredTenant = () => {
|
||||||
|
const currentTenant = useCurrentTenant();
|
||||||
|
const tenantId = currentTenant?.id || '';
|
||||||
|
const isReady = !!tenantId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentTenant,
|
||||||
|
tenantId,
|
||||||
|
isReady,
|
||||||
|
isLoading: !isReady, // Indicates if we're still waiting for tenant
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTenantId;
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer } from 'lucide-react';
|
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer, Package, Activity, Clock } from 'lucide-react';
|
||||||
import { Button, Card, Badge, Select, Table, StatsGrid } from '../../../../components/ui';
|
import { Button, Card, Badge, Table, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||||
import type { TableColumn } from '../../../../components/ui';
|
import type { TableColumn } from '../../../../components/ui';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
import { LoadingSpinner } from '../../../../components/shared';
|
||||||
import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting';
|
import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting';
|
||||||
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
|
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
|
||||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||||
import { useModels } from '../../../../api/hooks/training';
|
import { useModels } from '../../../../api/hooks/training';
|
||||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
import { useTenantId } from '../../../../hooks/useTenantId';
|
||||||
import { ForecastResponse } from '../../../../api/types/forecasting';
|
import { ForecastResponse } from '../../../../api/types/forecasting';
|
||||||
import { forecastingService } from '../../../../api/services/forecasting';
|
import { forecastingService } from '../../../../api/services/forecasting';
|
||||||
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
|
|
||||||
const ForecastingPage: React.FC = () => {
|
const ForecastingPage: React.FC = () => {
|
||||||
const [selectedProduct, setSelectedProduct] = useState('');
|
const [selectedProduct, setSelectedProduct] = useState('');
|
||||||
@@ -20,8 +22,7 @@ const ForecastingPage: React.FC = () => {
|
|||||||
const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]);
|
const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]);
|
||||||
|
|
||||||
// Get tenant ID from tenant store
|
// Get tenant ID from tenant store
|
||||||
const currentTenant = useCurrentTenant();
|
const tenantId = useTenantId();
|
||||||
const tenantId = currentTenant?.id || '';
|
|
||||||
|
|
||||||
// Calculate date range based on selected period
|
// Calculate date range based on selected period
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
@@ -145,42 +146,6 @@ const ForecastingPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Extract weather data from all forecasts for 7-day view
|
|
||||||
const getWeatherImpact = (forecasts: ForecastResponse[]) => {
|
|
||||||
if (!forecasts || forecasts.length === 0) return null;
|
|
||||||
|
|
||||||
// Calculate average temperature across all forecast days
|
|
||||||
const avgTemp = forecasts.reduce((sum, f) => sum + (f.weather_temperature || 0), 0) / forecasts.length;
|
|
||||||
const tempRange = {
|
|
||||||
min: Math.min(...forecasts.map(f => f.weather_temperature || 0)),
|
|
||||||
max: Math.max(...forecasts.map(f => f.weather_temperature || 0))
|
|
||||||
};
|
|
||||||
|
|
||||||
// Aggregate weather descriptions
|
|
||||||
const weatherTypes = forecasts
|
|
||||||
.map(f => f.weather_description)
|
|
||||||
.filter(Boolean)
|
|
||||||
.reduce((acc, desc) => {
|
|
||||||
acc[desc] = (acc[desc] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>);
|
|
||||||
|
|
||||||
const dominantWeather = Object.entries(weatherTypes)
|
|
||||||
.sort(([,a], [,b]) => b - a)[0]?.[0] || 'N/A';
|
|
||||||
|
|
||||||
return {
|
|
||||||
avgTemperature: Math.round(avgTemp),
|
|
||||||
tempRange,
|
|
||||||
dominantWeather,
|
|
||||||
forecastDays: forecasts.length,
|
|
||||||
dailyForecasts: forecasts.map(f => ({
|
|
||||||
date: f.forecast_date,
|
|
||||||
temperature: f.weather_temperature,
|
|
||||||
description: f.weather_description,
|
|
||||||
predicted_demand: f.predicted_demand
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const forecastColumns: TableColumn[] = [
|
const forecastColumns: TableColumn[] = [
|
||||||
@@ -224,7 +189,6 @@ const ForecastingPage: React.FC = () => {
|
|||||||
// Use either current forecast data or fetched data
|
// Use either current forecast data or fetched data
|
||||||
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
|
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
|
||||||
const transformedForecasts = transformForecastsForTable(forecasts);
|
const transformedForecasts = transformForecastsForTable(forecasts);
|
||||||
const weatherImpact = getWeatherImpact(forecasts);
|
|
||||||
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
|
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
|
||||||
const hasError = forecastsError || ingredientsError || modelsError;
|
const hasError = forecastsError || ingredientsError || modelsError;
|
||||||
|
|
||||||
@@ -234,495 +198,370 @@ const ForecastingPage: React.FC = () => {
|
|||||||
? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100)
|
? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Get forecast insights from the latest forecast - only real backend data
|
// Get simplified forecast insights
|
||||||
const getForecastInsights = (forecast: ForecastResponse) => {
|
const getForecastInsights = (forecasts: ForecastResponse[]) => {
|
||||||
|
if (!forecasts || forecasts.length === 0) return [];
|
||||||
|
|
||||||
const insights = [];
|
const insights = [];
|
||||||
|
const avgConfidence = forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length;
|
||||||
|
|
||||||
// Weather data (only factual)
|
// Model confidence
|
||||||
if (forecast.weather_temperature) {
|
|
||||||
insights.push({
|
|
||||||
type: 'weather',
|
|
||||||
icon: Thermometer,
|
|
||||||
title: 'Temperatura',
|
|
||||||
description: `${forecast.weather_temperature}°C`,
|
|
||||||
impact: 'info'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forecast.weather_description) {
|
|
||||||
insights.push({
|
|
||||||
type: 'weather',
|
|
||||||
icon: CloudRain,
|
|
||||||
title: 'Condición Climática',
|
|
||||||
description: forecast.weather_description,
|
|
||||||
impact: 'info'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporal factors (only factual)
|
|
||||||
if (forecast.is_weekend) {
|
|
||||||
insights.push({
|
|
||||||
type: 'temporal',
|
|
||||||
icon: Calendar,
|
|
||||||
title: 'Fin de Semana',
|
|
||||||
description: 'Día de fin de semana',
|
|
||||||
impact: 'info'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forecast.is_holiday) {
|
|
||||||
insights.push({
|
|
||||||
type: 'temporal',
|
|
||||||
icon: Calendar,
|
|
||||||
title: 'Día Festivo',
|
|
||||||
description: 'Día festivo',
|
|
||||||
impact: 'info'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model confidence (factual)
|
|
||||||
insights.push({
|
insights.push({
|
||||||
type: 'model',
|
type: 'model',
|
||||||
icon: Target,
|
icon: Target,
|
||||||
title: 'Confianza del Modelo',
|
title: 'Confianza del Modelo',
|
||||||
description: `${Math.round(forecast.confidence_level * 100)}%`,
|
description: `${Math.round(avgConfidence * 100)}%`,
|
||||||
impact: forecast.confidence_level > 0.8 ? 'positive' : forecast.confidence_level > 0.6 ? 'moderate' : 'high'
|
impact: avgConfidence > 0.8 ? 'positive' : avgConfidence > 0.6 ? 'moderate' : 'high'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Algorithm used
|
||||||
|
if (forecasts[0]?.algorithm) {
|
||||||
|
insights.push({
|
||||||
|
type: 'algorithm',
|
||||||
|
icon: Brain,
|
||||||
|
title: 'Algoritmo',
|
||||||
|
description: forecasts[0].algorithm,
|
||||||
|
impact: 'info'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forecast period
|
||||||
|
insights.push({
|
||||||
|
type: 'period',
|
||||||
|
icon: Calendar,
|
||||||
|
title: 'Período',
|
||||||
|
description: `${forecasts.length} días predichos`,
|
||||||
|
impact: 'info'
|
||||||
});
|
});
|
||||||
|
|
||||||
return insights;
|
return insights;
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentInsights = forecasts.length > 0 ? getForecastInsights(forecasts[0]) : [];
|
const currentInsights = getForecastInsights(forecasts);
|
||||||
|
|
||||||
|
|
||||||
|
// Loading and error states - using project patterns
|
||||||
|
if (isLoading || !tenantId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-64">
|
||||||
|
<LoadingSpinner text="Cargando datos de predicción..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<TrendingUp className="mx-auto h-12 w-12 text-[var(--color-error)] mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
Error al cargar datos
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
{forecastsError?.message || ingredientsError?.message || modelsError?.message || 'Ha ocurrido un error inesperado'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Predicción de Demanda"
|
title="Predicción de Demanda"
|
||||||
description="Predicciones inteligentes basadas en IA para optimizar tu producción"
|
description="Sistema inteligente de predicción de demanda basado en IA"
|
||||||
action={
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline">
|
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Configurar
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Exportar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading && (
|
{/* Stats Grid - Similar to POSPage */}
|
||||||
<Card className="p-6 flex items-center justify-center">
|
<StatsGrid
|
||||||
<Loader className="h-6 w-6 animate-spin mr-2" />
|
stats={[
|
||||||
<span>
|
{
|
||||||
{isGenerating ? 'Generando nuevas predicciones...' : 'Cargando predicciones...'}
|
title: 'Ingredientes con Modelos',
|
||||||
</span>
|
value: products.length,
|
||||||
</Card>
|
variant: 'default' as const,
|
||||||
)}
|
icon: Brain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Predicciones Generadas',
|
||||||
|
value: forecasts.length,
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: TrendingUp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Confianza Promedio',
|
||||||
|
value: `${averageConfidence}%`,
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: Target,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Demanda Total',
|
||||||
|
value: formatters.number(Math.round(totalDemand)),
|
||||||
|
variant: 'warning' as const,
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columns={4}
|
||||||
|
/>
|
||||||
|
|
||||||
{hasError && (
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<Card className="p-6 bg-[var(--color-error-50)] border-[var(--color-error-200)]">
|
{/* Ingredient Selection Section */}
|
||||||
<div className="flex items-center">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<AlertTriangle className="h-5 w-5 text-[var(--color-error-600)] mr-2" />
|
{/* Ingredients Grid - Similar to POSPage products */}
|
||||||
<span className="text-[var(--color-error-800)]">Error al cargar las predicciones. Por favor, inténtalo de nuevo.</span>
|
<Card className="p-4">
|
||||||
</div>
|
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||||
</Card>
|
<Brain className="w-5 h-5 mr-2" />
|
||||||
)}
|
Ingredientes Disponibles ({products.length})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{products.map(product => {
|
||||||
|
const isSelected = selectedProduct === product.id;
|
||||||
|
|
||||||
{!isLoading && !hasError && (
|
const getStatusConfig = () => {
|
||||||
<>
|
if (isSelected) {
|
||||||
|
return {
|
||||||
|
color: getStatusColor('completed'),
|
||||||
|
text: 'Seleccionado',
|
||||||
|
icon: Target,
|
||||||
|
isCritical: false,
|
||||||
|
isHighlight: true
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
color: getStatusColor('pending'),
|
||||||
|
text: 'Disponible',
|
||||||
|
icon: Brain,
|
||||||
|
isCritical: false,
|
||||||
|
isHighlight: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
</>
|
return (
|
||||||
)}
|
<StatusCard
|
||||||
|
key={product.id}
|
||||||
|
id={product.id}
|
||||||
|
statusIndicator={getStatusConfig()}
|
||||||
|
title={product.name}
|
||||||
|
subtitle={product.category}
|
||||||
|
primaryValue="Modelo IA"
|
||||||
|
primaryValueLabel="entrenado"
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: isSelected ? 'Seleccionado' : 'Seleccionar',
|
||||||
|
icon: isSelected ? Target : Zap,
|
||||||
|
variant: isSelected ? 'success' : 'primary',
|
||||||
|
priority: 'primary',
|
||||||
|
disabled: isGenerating,
|
||||||
|
onClick: () => setSelectedProduct(product.id)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Forecast Configuration */}
|
{/* Empty State */}
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Configurar Predicción</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{/* Step 1: Select Ingredient */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
||||||
<span className="flex items-center">
|
|
||||||
<span className="bg-[var(--color-info-600)] text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">1</span>
|
|
||||||
Seleccionar Ingrediente
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedProduct}
|
|
||||||
onChange={(e) => setSelectedProduct(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
|
||||||
disabled={isGenerating}
|
|
||||||
>
|
|
||||||
<option value="">Selecciona un ingrediente...</option>
|
|
||||||
{products.map(product => (
|
|
||||||
<option key={product.id} value={product.id}>
|
|
||||||
🤖 {product.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{products.length === 0 && (
|
{products.length === 0 && (
|
||||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
<div className="text-center py-12">
|
||||||
No hay ingredientes con modelos entrenados
|
<Brain className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||||
</p>
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
No hay modelos entrenados
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
Para generar predicciones, necesitas modelos IA entrenados para tus ingredientes
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/app/database/models'}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Entrenar Modelos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Step 2: Select Period */}
|
{/* Configuration and Generation Section */}
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
{/* Period Selection */}
|
||||||
<span className="flex items-center">
|
<Card className="p-6">
|
||||||
<span className={`rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2 ${
|
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||||
selectedProduct ? 'bg-[var(--color-info-600)] text-white' : 'bg-gray-300 text-gray-600'
|
<Calendar className="w-5 h-5 mr-2" />
|
||||||
}`}>2</span>
|
Configuración
|
||||||
Período de Predicción
|
</h3>
|
||||||
</span>
|
<div className="space-y-4">
|
||||||
</label>
|
<div>
|
||||||
<select
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||||
value={forecastPeriod}
|
Período de Predicción
|
||||||
onChange={(e) => setForecastPeriod(e.target.value)}
|
</label>
|
||||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
<select
|
||||||
disabled={!selectedProduct || isGenerating}
|
value={forecastPeriod}
|
||||||
>
|
onChange={(e) => setForecastPeriod(e.target.value)}
|
||||||
{periods.map(period => (
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||||
<option key={period.value} value={period.value}>{period.label}</option>
|
disabled={isGenerating}
|
||||||
))}
|
>
|
||||||
</select>
|
{periods.map(period => (
|
||||||
</div>
|
<option key={period.value} value={period.value}>{period.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Step 3: Generate */}
|
{selectedProduct && (
|
||||||
<div>
|
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
<span className="flex items-center">
|
{products.find(p => p.id === selectedProduct)?.name}
|
||||||
<span className={`rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2 ${
|
</p>
|
||||||
selectedProduct && forecastPeriod ? 'bg-[var(--color-info-600)] text-white' : 'bg-gray-300 text-gray-600'
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
}`}>3</span>
|
Predicción para {forecastPeriod} días
|
||||||
Generar Predicción
|
</p>
|
||||||
</span>
|
</div>
|
||||||
</label>
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Generate Forecast */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||||
|
<Zap className="w-5 h-5 mr-2" />
|
||||||
|
Generar Predicción
|
||||||
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleGenerateForecast}
|
onClick={handleGenerateForecast}
|
||||||
disabled={!selectedProduct || !forecastPeriod || isGenerating}
|
disabled={!selectedProduct || !forecastPeriod || isGenerating}
|
||||||
className="w-full bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary)]/90"
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<>
|
<>
|
||||||
<Loader className="w-4 h-4 mr-2 animate-spin" />
|
<Loader className="w-5 h-5 mr-2 animate-spin" />
|
||||||
Generando...
|
Generando Predicción...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Zap className="w-4 h-4 mr-2" />
|
<Zap className="w-5 h-5 mr-2" />
|
||||||
Generar Predicción
|
Generar Predicción IA
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
{!selectedProduct && (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-2 text-center">
|
||||||
|
Selecciona un ingrediente para continuar
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedProduct && (
|
{/* Results Section */}
|
||||||
<div className="mt-4 p-3 bg-[var(--color-info-50)] border border-[var(--color-info-200)] rounded-lg">
|
|
||||||
<p className="text-sm text-[var(--color-info-800)]">
|
|
||||||
<strong>Ingrediente seleccionado:</strong> {products.find(p => p.id === selectedProduct)?.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--color-info-600)] mt-1">
|
|
||||||
Se generará una predicción de demanda para los próximos {forecastPeriod} días usando IA
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Results Section - Only show after generating forecast */}
|
|
||||||
{hasGeneratedForecast && forecasts.length > 0 && (
|
{hasGeneratedForecast && forecasts.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Enhanced Layout Structure */}
|
{/* Results Header */}
|
||||||
<div className="space-y-8">
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
{/* Key Metrics Row - Using StatsGrid */}
|
<div>
|
||||||
<StatsGrid
|
<h3 className="text-xl font-bold text-[var(--text-primary)]">Resultados de Predicción</h3>
|
||||||
columns={4}
|
<p className="text-[var(--text-secondary)]">
|
||||||
gap="md"
|
{products.find(p => p.id === selectedProduct)?.name} • {forecastPeriod} días
|
||||||
stats={[
|
</p>
|
||||||
{
|
|
||||||
title: "Confianza del Modelo",
|
|
||||||
value: `${averageConfidence}%`,
|
|
||||||
icon: Target,
|
|
||||||
variant: "success",
|
|
||||||
size: "sm"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Demanda Total",
|
|
||||||
value: Math.round(totalDemand),
|
|
||||||
icon: TrendingUp,
|
|
||||||
variant: "info",
|
|
||||||
size: "sm",
|
|
||||||
subtitle: `próximos ${forecastPeriod} días`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Días Predichos",
|
|
||||||
value: forecasts.length,
|
|
||||||
icon: Calendar,
|
|
||||||
variant: "default",
|
|
||||||
size: "sm"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Variabilidad",
|
|
||||||
value: (Math.max(...forecasts.map(f => f.predicted_demand)) - Math.min(...forecasts.map(f => f.predicted_demand))).toFixed(1),
|
|
||||||
icon: BarChart3,
|
|
||||||
variant: "warning",
|
|
||||||
size: "sm"
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main Content Grid */}
|
|
||||||
<div className="grid grid-cols-12 gap-6">
|
|
||||||
|
|
||||||
{/* Chart Section - Takes most space */}
|
|
||||||
<div className="col-span-12 lg:col-span-8">
|
|
||||||
<Card className="h-full">
|
|
||||||
<div className="p-6 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold text-gray-900">Predicción de Demanda</h3>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
{products.find(p => p.id === selectedProduct)?.name} • {forecastPeriod} días • {forecasts.length} puntos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="flex rounded-lg border border-gray-300 overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('chart')}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
viewMode === 'chart'
|
|
||||||
? 'bg-[var(--color-info-600)] text-white'
|
|
||||||
: 'bg-white text-gray-700 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
📊 Gráfico
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('table')}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors border-l ${
|
|
||||||
viewMode === 'table'
|
|
||||||
? 'bg-[var(--color-info-600)] text-white'
|
|
||||||
: 'bg-white text-gray-700 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
📋 Tabla
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Exportar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{viewMode === 'chart' ? (
|
|
||||||
<DemandChart
|
|
||||||
data={forecasts}
|
|
||||||
product={selectedProduct}
|
|
||||||
period={forecastPeriod}
|
|
||||||
loading={isLoading}
|
|
||||||
error={hasError ? 'Error al cargar las predicciones' : null}
|
|
||||||
height={450}
|
|
||||||
title=""
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ForecastTable forecasts={transformedForecasts} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
{/* Right Sidebar - Insights */}
|
<div className="flex rounded-lg border border-[var(--border-secondary)] overflow-hidden">
|
||||||
<div className="col-span-12 lg:col-span-4 space-y-6">
|
<Button
|
||||||
{/* Weather & External Factors */}
|
variant={viewMode === 'chart' ? 'primary' : 'outline'}
|
||||||
<div className="space-y-6">
|
onClick={() => setViewMode('chart')}
|
||||||
{/* Forecast Insights */}
|
size="sm"
|
||||||
{currentInsights.length > 0 && (
|
>
|
||||||
<Card className="p-6">
|
<BarChart3 className="w-4 h-4 mr-1" />
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Factores que Afectan la Predicción</h3>
|
Gráfico
|
||||||
<div className="space-y-3">
|
</Button>
|
||||||
{currentInsights.map((insight, index) => {
|
<Button
|
||||||
const IconComponent = insight.icon;
|
variant={viewMode === 'table' ? 'primary' : 'outline'}
|
||||||
return (
|
onClick={() => setViewMode('table')}
|
||||||
<div key={index} className="flex items-start space-x-3 p-3 bg-[var(--bg-secondary)] rounded-lg">
|
size="sm"
|
||||||
<div className={`p-2 rounded-lg ${
|
>
|
||||||
insight.impact === 'positive' ? 'bg-[var(--color-success-100)] text-[var(--color-success-600)]' :
|
<Package className="w-4 h-4 mr-1" />
|
||||||
insight.impact === 'high' ? 'bg-[var(--color-error-100)] text-[var(--color-error-600)]' :
|
Tabla
|
||||||
insight.impact === 'moderate' ? 'bg-[var(--color-warning-100)] text-[var(--color-warning-600)]' :
|
</Button>
|
||||||
'bg-[var(--color-info-100)] text-[var(--color-info-600)]'
|
</div>
|
||||||
}`}>
|
<Button variant="outline" size="sm">
|
||||||
<IconComponent className="w-4 h-4" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
</div>
|
Exportar
|
||||||
<div className="flex-1">
|
</Button>
|
||||||
<p className="text-sm font-medium text-[var(--text-primary)]">{insight.title}</p>
|
|
||||||
<p className="text-xs text-[var(--text-secondary)]">{insight.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Weather Impact */}
|
|
||||||
{weatherImpact && (
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="bg-gradient-to-r from-[var(--color-warning-500)] to-[var(--color-error-500)] p-4">
|
|
||||||
<h3 className="text-lg font-bold text-white flex items-center">
|
|
||||||
<Sun className="w-5 h-5 mr-2" />
|
|
||||||
Clima ({weatherImpact.forecastDays} días)
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--color-warning-100)] text-sm">Impacto meteorológico en la demanda</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
{/* Temperature Overview */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="bg-[var(--color-warning-50)] p-3 rounded-lg border border-[var(--color-warning-200)]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Thermometer className="w-4 h-4 text-[var(--color-warning-600)]" />
|
|
||||||
<span className="text-lg font-bold text-[var(--color-warning-800)]">{weatherImpact.avgTemperature}°C</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--color-warning-600)] mt-1">Promedio</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-[var(--color-info-50)] p-3 rounded-lg border border-[var(--color-info-200)]">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm font-medium text-[var(--color-info-800)]">{weatherImpact.dominantWeather}</p>
|
|
||||||
<p className="text-xs text-[var(--color-info-600)] mt-1">Condición</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Daily forecast - compact */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs font-medium text-gray-700">Pronóstico detallado:</p>
|
|
||||||
<div className="max-h-24 overflow-y-auto space-y-1">
|
|
||||||
{weatherImpact.dailyForecasts.slice(0, 5).map((day, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between text-xs p-2 bg-gray-50 rounded">
|
|
||||||
<span className="text-gray-600 font-medium">
|
|
||||||
{new Date(day.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-[var(--color-warning-600)]">{day.temperature}°C</span>
|
|
||||||
<span className="text-[var(--color-info-600)] font-bold">{day.predicted_demand?.toFixed(0)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{/* Model Information */}
|
|
||||||
{forecasts.length > 0 && (
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="bg-gradient-to-r from-[var(--chart-quinary)] to-[var(--color-info-700)] p-4">
|
|
||||||
<h3 className="text-lg font-bold text-white flex items-center">
|
|
||||||
<Brain className="w-5 h-5 mr-2" />
|
|
||||||
Modelo IA
|
|
||||||
</h3>
|
|
||||||
<p className="text-purple-100 text-sm">Información técnica del algoritmo</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
<div className="flex items-center justify-between p-2 bg-[var(--color-info-50)] rounded border border-[var(--color-info-200)]">
|
|
||||||
<span className="text-sm font-medium text-[var(--color-info-800)]">Algoritmo</span>
|
|
||||||
<Badge variant="purple">{forecasts[0]?.algorithm || 'N/A'}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
|
||||||
<span className="text-xs text-gray-600">Versión</span>
|
|
||||||
<span className="text-xs font-mono text-gray-800">{forecasts[0]?.model_version || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
|
||||||
<span className="text-xs text-gray-600">Tiempo</span>
|
|
||||||
<span className="text-xs font-mono text-gray-800">{forecasts[0]?.processing_time_ms || 'N/A'}ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
{/* Chart or Table */}
|
||||||
|
<div className="min-h-96">
|
||||||
|
{viewMode === 'chart' ? (
|
||||||
|
<DemandChart
|
||||||
|
data={forecasts}
|
||||||
|
product={selectedProduct}
|
||||||
|
period={forecastPeriod}
|
||||||
|
loading={isLoading}
|
||||||
|
error={hasError ? 'Error al cargar las predicciones' : null}
|
||||||
|
height={400}
|
||||||
|
title=""
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ForecastTable forecasts={transformedForecasts} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Insights */}
|
||||||
|
{currentInsights.length > 0 && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
|
||||||
|
<Activity className="w-5 h-5 mr-2" />
|
||||||
|
Factores de Influencia
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{currentInsights.map((insight, index) => {
|
||||||
|
const IconComponent = insight.icon;
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-start space-x-3 p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||||
|
<div className={`p-2 rounded-lg ${
|
||||||
|
insight.impact === 'positive' ? 'bg-[var(--color-success-100)] text-[var(--color-success-600)]' :
|
||||||
|
insight.impact === 'high' ? 'bg-[var(--color-error-100)] text-[var(--color-error-600)]' :
|
||||||
|
insight.impact === 'moderate' ? 'bg-[var(--color-warning-100)] text-[var(--color-warning-600)]' :
|
||||||
|
'bg-[var(--color-info-100)] text-[var(--color-info-600)]'
|
||||||
|
}`}>
|
||||||
|
<IconComponent className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-[var(--text-primary)]">{insight.title}</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">{insight.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Detailed Forecasts Table */}
|
{/* Help Section - Only when no models available */}
|
||||||
{!isLoading && !hasError && transformedForecasts.length > 0 && (
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Predicciones Detalladas</h3>
|
|
||||||
<Table
|
|
||||||
columns={forecastColumns}
|
|
||||||
data={transformedForecasts}
|
|
||||||
rowKey="id"
|
|
||||||
hover={true}
|
|
||||||
variant="default"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty States */}
|
|
||||||
{!isLoading && !hasError && products.length === 0 && (
|
{!isLoading && !hasError && products.length === 0 && (
|
||||||
<Card className="p-6 text-center">
|
<Card className="p-6 text-center">
|
||||||
<div className="py-8">
|
<div className="py-8">
|
||||||
<TrendingUp className="h-12 w-12 text-[var(--color-warning-400)] mx-auto mb-4" />
|
<Brain className="h-12 w-12 text-[var(--color-info)] mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-600 mb-2">No hay ingredientes con modelos entrenados</h3>
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
<p className="text-gray-500 mb-6">
|
No hay modelos entrenados
|
||||||
Para generar predicciones, primero necesitas entrenar modelos de IA para tus ingredientes.
|
</h3>
|
||||||
Ve a la página de Modelos IA para entrenar modelos para tus ingredientes.
|
<p className="text-[var(--text-secondary)] mb-6">
|
||||||
|
Para generar predicciones, necesitas entrenar modelos de IA para tus ingredientes.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center space-x-4">
|
<Button
|
||||||
<Button
|
onClick={() => window.location.href = '/app/database/models'}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => {
|
>
|
||||||
window.location.href = '/app/database/models';
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
}}
|
Entrenar Modelos IA
|
||||||
>
|
</Button>
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Configurar Modelos IA
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && !hasError && products.length > 0 && !hasGeneratedForecast && (
|
|
||||||
<Card className="p-6 text-center">
|
|
||||||
<div className="py-8">
|
|
||||||
<BarChart3 className="h-12 w-12 text-[var(--color-info-400)] mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-600 mb-2">Listo para Generar Predicciones</h3>
|
|
||||||
<p className="text-gray-500 mb-6">
|
|
||||||
Tienes {products.length} ingrediente{products.length > 1 ? 's' : ''} con modelos entrenados disponibles.
|
|
||||||
Selecciona un ingrediente y período para comenzar.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-2xl mx-auto">
|
|
||||||
<div className="text-center p-4 border rounded-lg">
|
|
||||||
<div className="bg-[var(--color-info-600)] text-white rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">1</div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Selecciona Ingrediente</p>
|
|
||||||
<p className="text-xs text-gray-500">Elige un ingrediente con modelo IA</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-4 border rounded-lg">
|
|
||||||
<div className="bg-gray-300 text-gray-600 rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">2</div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Define Período</p>
|
|
||||||
<p className="text-xs text-gray-500">Establece días a predecir</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-4 border rounded-lg">
|
|
||||||
<div className="bg-gray-300 text-gray-600 rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">3</div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Generar Predicción</p>
|
|
||||||
<p className="text-xs text-gray-500">Obtén insights de IA</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ import {
|
|||||||
// Import AddStockModal separately since we need it for adding batches
|
// Import AddStockModal separately since we need it for adding batches
|
||||||
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
|
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
|
||||||
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
|
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
|
||||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
import { useTenantId } from '../../../../hooks/useTenantId';
|
||||||
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
|
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
|
||||||
|
|
||||||
const InventoryPage: React.FC = () => {
|
const InventoryPage: React.FC = () => {
|
||||||
@@ -30,8 +30,7 @@ const InventoryPage: React.FC = () => {
|
|||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showAddBatch, setShowAddBatch] = useState(false);
|
const [showAddBatch, setShowAddBatch] = useState(false);
|
||||||
|
|
||||||
const currentTenant = useCurrentTenant();
|
const tenantId = useTenantId();
|
||||||
const tenantId = currentTenant?.id || '';
|
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const createIngredientMutation = useCreateIngredient();
|
const createIngredientMutation = useCreateIngredient();
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt } from 'lucide-react';
|
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
import { LoadingSpinner } from '../../../../components/shared';
|
||||||
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
|
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||||
|
import { useTenantId } from '../../../../hooks/useTenantId';
|
||||||
|
import { ProductType, ProductCategory, IngredientResponse } from '../../../../api/types/inventory';
|
||||||
|
|
||||||
|
interface CartItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
category: string;
|
||||||
|
stock: number;
|
||||||
|
}
|
||||||
|
|
||||||
const POSPage: React.FC = () => {
|
const POSPage: React.FC = () => {
|
||||||
const [cart, setCart] = useState<Array<{
|
const [cart, setCart] = useState<CartItem[]>([]);
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
quantity: number;
|
|
||||||
category: string;
|
|
||||||
}>>([]);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||||
const [customerInfo, setCustomerInfo] = useState({
|
const [customerInfo, setCustomerInfo] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -20,73 +28,65 @@ const POSPage: React.FC = () => {
|
|||||||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
|
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
|
||||||
const [cashReceived, setCashReceived] = useState('');
|
const [cashReceived, setCashReceived] = useState('');
|
||||||
|
|
||||||
const products = [
|
const tenantId = useTenantId();
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'Pan de Molde Integral',
|
|
||||||
price: 4.50,
|
|
||||||
category: 'bread',
|
|
||||||
stock: 25,
|
|
||||||
image: '/api/placeholder/100/100',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'Croissants de Mantequilla',
|
|
||||||
price: 1.50,
|
|
||||||
category: 'pastry',
|
|
||||||
stock: 32,
|
|
||||||
image: '/api/placeholder/100/100',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: 'Baguette Francesa',
|
|
||||||
price: 2.80,
|
|
||||||
category: 'bread',
|
|
||||||
stock: 18,
|
|
||||||
image: '/api/placeholder/100/100',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
name: 'Tarta de Chocolate',
|
|
||||||
price: 25.00,
|
|
||||||
category: 'cake',
|
|
||||||
stock: 8,
|
|
||||||
image: '/api/placeholder/100/100',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
name: 'Magdalenas',
|
|
||||||
price: 0.75,
|
|
||||||
category: 'pastry',
|
|
||||||
stock: 48,
|
|
||||||
image: '/api/placeholder/100/100',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '6',
|
|
||||||
name: 'Empanadas',
|
|
||||||
price: 2.50,
|
|
||||||
category: 'other',
|
|
||||||
stock: 24,
|
|
||||||
image: '/api/placeholder/100/100',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const categories = [
|
// Fetch finished products from API
|
||||||
{ id: 'all', name: 'Todos' },
|
const {
|
||||||
{ id: 'bread', name: 'Panes' },
|
data: ingredientsData,
|
||||||
{ id: 'pastry', name: 'Bollería' },
|
isLoading: productsLoading,
|
||||||
{ id: 'cake', name: 'Tartas' },
|
error: productsError
|
||||||
{ id: 'other', name: 'Otros' },
|
} = useIngredients(tenantId, {
|
||||||
];
|
// Filter for finished products only
|
||||||
|
category: undefined, // We'll filter client-side for now
|
||||||
|
search: undefined
|
||||||
|
});
|
||||||
|
|
||||||
const filteredProducts = products.filter(product =>
|
// Filter for finished products and convert to POS format
|
||||||
selectedCategory === 'all' || product.category === selectedCategory
|
const products = useMemo(() => {
|
||||||
);
|
if (!ingredientsData) return [];
|
||||||
|
|
||||||
|
return ingredientsData
|
||||||
|
.filter(ingredient => ingredient.product_type === ProductType.FINISHED_PRODUCT)
|
||||||
|
.map(ingredient => ({
|
||||||
|
id: ingredient.id,
|
||||||
|
name: ingredient.name,
|
||||||
|
price: Number(ingredient.average_cost) || 0,
|
||||||
|
category: ingredient.category.toLowerCase(),
|
||||||
|
stock: Number(ingredient.current_stock) || 0,
|
||||||
|
ingredient: ingredient
|
||||||
|
}))
|
||||||
|
.filter(product => product.stock > 0); // Only show products in stock
|
||||||
|
}, [ingredientsData]);
|
||||||
|
|
||||||
|
// Generate categories from actual product data
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const categoryMap = new Map();
|
||||||
|
categoryMap.set('all', { id: 'all', name: 'Todos' });
|
||||||
|
|
||||||
|
products.forEach(product => {
|
||||||
|
if (!categoryMap.has(product.category)) {
|
||||||
|
const categoryName = product.category.charAt(0).toUpperCase() + product.category.slice(1);
|
||||||
|
categoryMap.set(product.category, { id: product.category, name: categoryName });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(categoryMap.values());
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
return products.filter(product =>
|
||||||
|
selectedCategory === 'all' || product.category === selectedCategory
|
||||||
|
);
|
||||||
|
}, [products, selectedCategory]);
|
||||||
|
|
||||||
const addToCart = (product: typeof products[0]) => {
|
const addToCart = (product: typeof products[0]) => {
|
||||||
setCart(prevCart => {
|
setCart(prevCart => {
|
||||||
const existingItem = prevCart.find(item => item.id === product.id);
|
const existingItem = prevCart.find(item => item.id === product.id);
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
|
// Check if we have enough stock
|
||||||
|
if (existingItem.quantity >= product.stock) {
|
||||||
|
return prevCart; // Don't add if no stock available
|
||||||
|
}
|
||||||
return prevCart.map(item =>
|
return prevCart.map(item =>
|
||||||
item.id === product.id
|
item.id === product.id
|
||||||
? { ...item, quantity: item.quantity + 1 }
|
? { ...item, quantity: item.quantity + 1 }
|
||||||
@@ -99,6 +99,7 @@ const POSPage: React.FC = () => {
|
|||||||
price: product.price,
|
price: product.price,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
category: product.category,
|
category: product.category,
|
||||||
|
stock: product.stock
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -109,9 +110,14 @@ const POSPage: React.FC = () => {
|
|||||||
setCart(prevCart => prevCart.filter(item => item.id !== id));
|
setCart(prevCart => prevCart.filter(item => item.id !== id));
|
||||||
} else {
|
} else {
|
||||||
setCart(prevCart =>
|
setCart(prevCart =>
|
||||||
prevCart.map(item =>
|
prevCart.map(item => {
|
||||||
item.id === id ? { ...item, quantity } : item
|
if (item.id === id) {
|
||||||
)
|
// Don't allow quantity to exceed stock
|
||||||
|
const maxQuantity = Math.min(quantity, item.stock);
|
||||||
|
return { ...item, quantity: maxQuantity };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -128,8 +134,8 @@ const POSPage: React.FC = () => {
|
|||||||
|
|
||||||
const processPayment = () => {
|
const processPayment = () => {
|
||||||
if (cart.length === 0) return;
|
if (cart.length === 0) return;
|
||||||
|
|
||||||
// Process payment logic here
|
// TODO: Integrate with real POS API endpoint
|
||||||
console.log('Processing payment:', {
|
console.log('Processing payment:', {
|
||||||
cart,
|
cart,
|
||||||
customerInfo,
|
customerInfo,
|
||||||
@@ -143,53 +149,205 @@ const POSPage: React.FC = () => {
|
|||||||
setCart([]);
|
setCart([]);
|
||||||
setCustomerInfo({ name: '', email: '', phone: '' });
|
setCustomerInfo({ name: '', email: '', phone: '' });
|
||||||
setCashReceived('');
|
setCashReceived('');
|
||||||
|
|
||||||
alert('Venta procesada exitosamente');
|
alert('Venta procesada exitosamente');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate stats for the POS dashboard
|
||||||
|
const posStats = useMemo(() => {
|
||||||
|
const totalProducts = products.length;
|
||||||
|
const totalStock = products.reduce((sum, product) => sum + product.stock, 0);
|
||||||
|
const cartValue = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||||
|
const cartItems = cart.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
const lowStockProducts = products.filter(product => product.stock <= 5).length;
|
||||||
|
const avgProductPrice = totalProducts > 0 ? products.reduce((sum, product) => sum + product.price, 0) / totalProducts : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalProducts,
|
||||||
|
totalStock,
|
||||||
|
cartValue,
|
||||||
|
cartItems,
|
||||||
|
lowStockProducts,
|
||||||
|
avgProductPrice
|
||||||
|
};
|
||||||
|
}, [products, cart]);
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: 'Productos Disponibles',
|
||||||
|
value: posStats.totalProducts,
|
||||||
|
variant: 'default' as const,
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Stock Total',
|
||||||
|
value: posStats.totalStock,
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Artículos en Carrito',
|
||||||
|
value: posStats.cartItems,
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: ShoppingCart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Valor del Carrito',
|
||||||
|
value: formatters.currency(posStats.cartValue),
|
||||||
|
variant: 'success' as const,
|
||||||
|
icon: Euro,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Stock Bajo',
|
||||||
|
value: posStats.lowStockProducts,
|
||||||
|
variant: 'warning' as const,
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Precio Promedio',
|
||||||
|
value: formatters.currency(posStats.avgProductPrice),
|
||||||
|
variant: 'info' as const,
|
||||||
|
icon: TrendingUp,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Loading and error states
|
||||||
|
if (productsLoading || !tenantId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-64">
|
||||||
|
<LoadingSpinner text="Cargando productos..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productsError) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Package className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
Error al cargar productos
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
{productsError.message || 'Ha ocurrido un error inesperado'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 h-screen flex flex-col">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Punto de Venta"
|
title="Punto de Venta"
|
||||||
description="Sistema de ventas integrado"
|
description="Sistema de ventas para productos terminados"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
|
{/* Stats Grid */}
|
||||||
|
<StatsGrid
|
||||||
|
stats={stats}
|
||||||
|
columns={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Products Section */}
|
{/* Products Section */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
<div className="flex space-x-2 overflow-x-auto">
|
<Card className="p-4">
|
||||||
{categories.map(category => (
|
<div className="flex space-x-2 overflow-x-auto">
|
||||||
<Button
|
{categories.map(category => (
|
||||||
key={category.id}
|
<Button
|
||||||
variant={selectedCategory === category.id ? 'default' : 'outline'}
|
key={category.id}
|
||||||
onClick={() => setSelectedCategory(category.id)}
|
variant={selectedCategory === category.id ? 'default' : 'outline'}
|
||||||
className="whitespace-nowrap"
|
onClick={() => setSelectedCategory(category.id)}
|
||||||
>
|
className="whitespace-nowrap"
|
||||||
{category.name}
|
>
|
||||||
</Button>
|
{category.name}
|
||||||
))}
|
</Button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Products Grid */}
|
{/* Products Grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredProducts.map(product => (
|
{filteredProducts.map(product => {
|
||||||
<Card
|
const cartItem = cart.find(item => item.id === product.id);
|
||||||
key={product.id}
|
const inCart = !!cartItem;
|
||||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
|
const cartQuantity = cartItem?.quantity || 0;
|
||||||
onClick={() => addToCart(product)}
|
const remainingStock = product.stock - cartQuantity;
|
||||||
>
|
|
||||||
<img
|
const getStockStatusConfig = () => {
|
||||||
src={product.image}
|
if (remainingStock <= 0) {
|
||||||
alt={product.name}
|
return {
|
||||||
className="w-full h-20 object-cover rounded mb-3"
|
color: getStatusColor('cancelled'),
|
||||||
|
text: 'Sin Stock',
|
||||||
|
icon: Package,
|
||||||
|
isCritical: true,
|
||||||
|
isHighlight: false
|
||||||
|
};
|
||||||
|
} else if (remainingStock <= 5) {
|
||||||
|
return {
|
||||||
|
color: getStatusColor('pending'),
|
||||||
|
text: `${remainingStock} disponibles`,
|
||||||
|
icon: Package,
|
||||||
|
isCritical: false,
|
||||||
|
isHighlight: true
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
color: getStatusColor('completed'),
|
||||||
|
text: `${remainingStock} disponibles`,
|
||||||
|
icon: Package,
|
||||||
|
isCritical: false,
|
||||||
|
isHighlight: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusCard
|
||||||
|
key={product.id}
|
||||||
|
id={product.id}
|
||||||
|
statusIndicator={getStockStatusConfig()}
|
||||||
|
title={product.name}
|
||||||
|
subtitle={product.category.charAt(0).toUpperCase() + product.category.slice(1)}
|
||||||
|
primaryValue={formatters.currency(product.price)}
|
||||||
|
primaryValueLabel="precio"
|
||||||
|
secondaryInfo={inCart ? {
|
||||||
|
label: 'En carrito',
|
||||||
|
value: cartQuantity.toString()
|
||||||
|
} : undefined}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Agregar al Carrito',
|
||||||
|
icon: Plus,
|
||||||
|
variant: 'primary',
|
||||||
|
priority: 'primary',
|
||||||
|
disabled: remainingStock <= 0,
|
||||||
|
onClick: () => addToCart(product)
|
||||||
|
}
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<h3 className="font-medium text-sm mb-1 line-clamp-2">{product.name}</h3>
|
);
|
||||||
<p className="text-lg font-bold text-[var(--color-success)]">€{product.price.toFixed(2)}</p>
|
})}
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">Stock: {product.stock}</p>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredProducts.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||||
|
No hay productos disponibles
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
{selectedCategory === 'all'
|
||||||
|
? 'No hay productos en stock en este momento'
|
||||||
|
: `No hay productos en la categoría "${categories.find(c => c.id === selectedCategory)?.name}"`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cart and Checkout Section */}
|
{/* Cart and Checkout Section */}
|
||||||
@@ -212,40 +370,47 @@ const POSPage: React.FC = () => {
|
|||||||
{cart.length === 0 ? (
|
{cart.length === 0 ? (
|
||||||
<p className="text-[var(--text-tertiary)] text-center py-8">Carrito vacío</p>
|
<p className="text-[var(--text-tertiary)] text-center py-8">Carrito vacío</p>
|
||||||
) : (
|
) : (
|
||||||
cart.map(item => (
|
cart.map(item => {
|
||||||
<div key={item.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
|
const product = products.find(p => p.id === item.id);
|
||||||
<div className="flex-1">
|
const maxQuantity = product?.stock || item.stock;
|
||||||
<h4 className="text-sm font-medium">{item.name}</h4>
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">€{item.price.toFixed(2)} c/u</p>
|
return (
|
||||||
|
<div key={item.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-medium">{item.name}</h4>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">€{item.price.toFixed(2)} c/u</p>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">Stock: {maxQuantity}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateQuantity(item.id, item.quantity - 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Minus className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="w-8 text-center text-sm">{item.quantity}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={item.quantity >= maxQuantity}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateQuantity(item.id, item.quantity + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 text-right">
|
||||||
|
<p className="text-sm font-medium">€{(item.price * item.quantity).toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
);
|
||||||
<Button
|
})
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
updateQuantity(item.id, item.quantity - 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Minus className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
<span className="w-8 text-center text-sm">{item.quantity}</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
updateQuantity(item.id, item.quantity + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 text-right">
|
|
||||||
<p className="text-sm font-medium">€{(item.price * item.quantity).toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -305,7 +470,7 @@ const POSPage: React.FC = () => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={paymentMethod === 'cash' ? 'default' : 'outline'}
|
variant={paymentMethod === 'cash' ? 'primary' : 'outline'}
|
||||||
onClick={() => setPaymentMethod('cash')}
|
onClick={() => setPaymentMethod('cash')}
|
||||||
className="flex items-center justify-center"
|
className="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
@@ -313,7 +478,7 @@ const POSPage: React.FC = () => {
|
|||||||
Efectivo
|
Efectivo
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={paymentMethod === 'card' ? 'default' : 'outline'}
|
variant={paymentMethod === 'card' ? 'primary' : 'outline'}
|
||||||
onClick={() => setPaymentMethod('card')}
|
onClick={() => setPaymentMethod('card')}
|
||||||
className="flex items-center justify-center"
|
className="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
@@ -321,7 +486,7 @@ const POSPage: React.FC = () => {
|
|||||||
Tarjeta
|
Tarjeta
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={paymentMethod === 'transfer' ? 'default' : 'outline'}
|
variant={paymentMethod === 'transfer' ? 'primary' : 'outline'}
|
||||||
onClick={() => setPaymentMethod('transfer')}
|
onClick={() => setPaymentMethod('transfer')}
|
||||||
className="flex items-center justify-center"
|
className="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ interface BusinessHours {
|
|||||||
const BakeryConfigPage: React.FC = () => {
|
const BakeryConfigPage: React.FC = () => {
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const currentTenant = useCurrentTenant();
|
const currentTenant = useCurrentTenant();
|
||||||
const { loadUserTenants } = useTenantActions();
|
const { loadUserTenants, setCurrentTenant } = useTenantActions();
|
||||||
const tenantId = currentTenant?.id || '';
|
const tenantId = currentTenant?.id || '';
|
||||||
|
|
||||||
// Use the current tenant from the store instead of making additional API calls
|
// Use the current tenant from the store instead of making additional API calls
|
||||||
@@ -83,12 +83,10 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
language: 'es'
|
language: 'es'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load user tenants on component mount if no current tenant
|
// Load user tenants on component mount to ensure fresh data
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentTenant) {
|
loadUserTenants();
|
||||||
loadUserTenants();
|
}, [loadUserTenants]);
|
||||||
}
|
|
||||||
}, [currentTenant, loadUserTenants]);
|
|
||||||
|
|
||||||
|
|
||||||
// Update config when tenant data is loaded
|
// Update config when tenant data is loaded
|
||||||
@@ -97,8 +95,8 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
setConfig({
|
setConfig({
|
||||||
name: tenant.name || '',
|
name: tenant.name || '',
|
||||||
description: tenant.description || '',
|
description: tenant.description || '',
|
||||||
email: tenant.email || '', // Fixed: use email instead of contact_email
|
email: tenant.email || '',
|
||||||
phone: tenant.phone || '', // Fixed: use phone instead of contact_phone
|
phone: tenant.phone || '',
|
||||||
website: tenant.website || '',
|
website: tenant.website || '',
|
||||||
address: tenant.address || '',
|
address: tenant.address || '',
|
||||||
city: tenant.city || '',
|
city: tenant.city || '',
|
||||||
@@ -106,9 +104,10 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
country: tenant.country || '',
|
country: tenant.country || '',
|
||||||
taxId: '', // Not supported by backend yet
|
taxId: '', // Not supported by backend yet
|
||||||
currency: 'EUR', // Default value
|
currency: 'EUR', // Default value
|
||||||
timezone: 'Europe/Madrid', // Default value
|
timezone: 'Europe/Madrid', // Default value
|
||||||
language: 'es' // Default value
|
language: 'es' // Default value
|
||||||
});
|
});
|
||||||
|
setHasUnsavedChanges(false); // Reset unsaved changes when loading fresh data
|
||||||
}
|
}
|
||||||
}, [tenant]);
|
}, [tenant]);
|
||||||
|
|
||||||
@@ -249,30 +248,48 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleSaveConfig = async () => {
|
const handleSaveConfig = async () => {
|
||||||
if (!validateConfig() || !tenantId) return;
|
if (!validateConfig() || !tenantId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateTenantMutation.mutateAsync({
|
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,
|
tenantId,
|
||||||
updateData: {
|
updateData
|
||||||
name: config.name,
|
|
||||||
description: config.description,
|
|
||||||
email: config.email, // Fixed: use email instead of contact_email
|
|
||||||
phone: config.phone, // Fixed: use phone instead of contact_phone
|
|
||||||
website: config.website,
|
|
||||||
address: config.address,
|
|
||||||
city: config.city,
|
|
||||||
postal_code: config.postalCode,
|
|
||||||
country: config.country
|
|
||||||
// Note: tax_id, currency, timezone, language might not be supported by backend
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
setHasUnsavedChanges(false);
|
||||||
addToast('Configuración actualizada correctamente', { type: 'success' });
|
addToast('Configuración actualizada correctamente', { type: 'success' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast('No se pudo actualizar la configuración', { type: 'error' });
|
addToast(`Error al actualizar: ${error instanceof Error ? error.message : 'Error desconocido'}`, { type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Users, Plus, Search, Mail, Phone, Shield, Trash2, Crown, X, UserCheck } from 'lucide-react';
|
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-react';
|
||||||
import { Button, Card, Badge, Input, StatusCard, getStatusColor, StatsGrid } from '../../../../components/ui';
|
import { Button, Card, Input, StatusCard, getStatusColor, StatsGrid } from '../../../../components/ui';
|
||||||
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
|
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole } from '../../../../api/hooks/tenant';
|
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
|
||||||
import { useAllUsers } from '../../../../api/hooks/user';
|
import { useAllUsers } from '../../../../api/hooks/user';
|
||||||
import { useAuthUser } from '../../../../stores/auth.store';
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
|
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
|
||||||
@@ -12,12 +12,23 @@ import { TENANT_ROLES } from '../../../../types/roles';
|
|||||||
|
|
||||||
const TeamPage: React.FC = () => {
|
const TeamPage: React.FC = () => {
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
const currentUser = useAuthUser();
|
||||||
const currentTenant = useCurrentTenant();
|
const currentTenant = useCurrentTenant();
|
||||||
const currentTenantAccess = useCurrentTenantAccess();
|
const currentTenantAccess = useCurrentTenantAccess();
|
||||||
const tenantId = currentTenant?.id || '';
|
const tenantId = currentTenant?.id || '';
|
||||||
|
|
||||||
|
// Try to get tenant access directly via hook as fallback
|
||||||
|
const { data: directTenantAccess } = useTenantAccess(
|
||||||
|
tenantId,
|
||||||
|
currentUser?.id || '',
|
||||||
|
{ enabled: !!tenantId && !!currentUser?.id && !currentTenantAccess }
|
||||||
|
);
|
||||||
|
|
||||||
const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive
|
const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive
|
||||||
const { data: allUsers = [] } = useAllUsers();
|
const { data: allUsers = [], error: allUsersError, isLoading: allUsersLoading } = useAllUsers({
|
||||||
|
retry: false, // Don't retry on permission errors
|
||||||
|
staleTime: 0 // Always fresh check for permissions
|
||||||
|
});
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const addMemberMutation = useAddTeamMember();
|
const addMemberMutation = useAddTeamMember();
|
||||||
@@ -72,9 +83,15 @@ const TeamPage: React.FC = () => {
|
|||||||
{ value: TENANT_ROLES.VIEWER, label: 'Observador', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.VIEWER).length }
|
{ value: TENANT_ROLES.VIEWER, label: 'Observador', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.VIEWER).length }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Use direct tenant access as fallback
|
||||||
|
const effectiveTenantAccess = currentTenantAccess || directTenantAccess;
|
||||||
|
|
||||||
|
// Check if current user is the tenant owner (fallback when access endpoint fails)
|
||||||
|
const isCurrentUserOwner = currentUser?.id === currentTenant?.owner_id;
|
||||||
|
|
||||||
// Permission checks
|
// Permission checks
|
||||||
const isOwner = currentTenantAccess?.role === TENANT_ROLES.OWNER;
|
const isOwner = effectiveTenantAccess?.role === TENANT_ROLES.OWNER || isCurrentUserOwner;
|
||||||
const canManageTeam = isOwner || currentTenantAccess?.role === TENANT_ROLES.ADMIN;
|
const canManageTeam = isOwner || effectiveTenantAccess?.role === TENANT_ROLES.ADMIN;
|
||||||
|
|
||||||
const teamStats = {
|
const teamStats = {
|
||||||
total: enhancedTeamMembers.length,
|
total: enhancedTeamMembers.length,
|
||||||
@@ -197,29 +214,38 @@ const TeamPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Available users for adding (exclude current members)
|
// Available users for adding (exclude current members)
|
||||||
const availableUsers = allUsers.filter(u =>
|
const availableUsers = allUsers.filter(u =>
|
||||||
!enhancedTeamMembers.some(m => m.user_id === u.id)
|
!enhancedTeamMembers.some(m => m.user_id === u.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Member action handlers
|
// Force reload tenant access if missing
|
||||||
const handleAddMember = async () => {
|
React.useEffect(() => {
|
||||||
if (!selectedUserToAdd || !selectedRoleToAdd || !tenantId) return;
|
if (currentTenant?.id && !currentTenantAccess) {
|
||||||
|
console.log('Forcing tenant access reload for tenant:', currentTenant.id);
|
||||||
try {
|
// You can trigger a manual reload here if needed
|
||||||
await addMemberMutation.mutateAsync({
|
|
||||||
tenantId,
|
|
||||||
userId: selectedUserToAdd,
|
|
||||||
role: selectedRoleToAdd,
|
|
||||||
});
|
|
||||||
|
|
||||||
addToast('Miembro agregado exitosamente', { type: 'success' });
|
|
||||||
setShowAddForm(false);
|
|
||||||
setSelectedUserToAdd('');
|
|
||||||
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
|
|
||||||
} catch (error) {
|
|
||||||
addToast('Error al agregar miembro', { type: 'error' });
|
|
||||||
}
|
}
|
||||||
};
|
}, [currentTenant?.id, currentTenantAccess]);
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('TeamPage Debug:', {
|
||||||
|
canManageTeam,
|
||||||
|
isOwner,
|
||||||
|
isCurrentUserOwner,
|
||||||
|
currentUser: currentUser?.id,
|
||||||
|
currentTenant: currentTenant?.id,
|
||||||
|
tenantOwner: currentTenant?.owner_id,
|
||||||
|
currentTenantAccess,
|
||||||
|
directTenantAccess,
|
||||||
|
effectiveTenantAccess,
|
||||||
|
tenantAccess: effectiveTenantAccess?.role,
|
||||||
|
allUsers: allUsers.length,
|
||||||
|
allUsersError,
|
||||||
|
allUsersLoading,
|
||||||
|
availableUsers: availableUsers.length,
|
||||||
|
enhancedTeamMembers: enhancedTeamMembers.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Member action handlers - removed unused handleAddMember since modal handles it directly
|
||||||
|
|
||||||
const handleRemoveMember = async (memberUserId: string) => {
|
const handleRemoveMember = async (memberUserId: string) => {
|
||||||
if (!tenantId) return;
|
if (!tenantId) return;
|
||||||
@@ -276,7 +302,7 @@ const TeamPage: React.FC = () => {
|
|||||||
title="Gestión de Equipo"
|
title="Gestión de Equipo"
|
||||||
description="Administra los miembros del equipo, roles y permisos"
|
description="Administra los miembros del equipo, roles y permisos"
|
||||||
actions={
|
actions={
|
||||||
canManageTeam && availableUsers.length > 0 ? [{
|
canManageTeam ? [{
|
||||||
id: 'add-member',
|
id: 'add-member',
|
||||||
label: 'Agregar Miembro',
|
label: 'Agregar Miembro',
|
||||||
icon: Plus,
|
icon: Plus,
|
||||||
@@ -351,7 +377,7 @@ const TeamPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Add Member Button */}
|
{/* Add Member Button */}
|
||||||
{canManageTeam && availableUsers.length > 0 && filteredMembers.length > 0 && (
|
{canManageTeam && filteredMembers.length > 0 && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowAddForm(true)}
|
onClick={() => setShowAddForm(true)}
|
||||||
@@ -406,7 +432,7 @@ const TeamPage: React.FC = () => {
|
|||||||
: "Este tenant aún no tiene miembros del equipo"
|
: "Este tenant aún no tiene miembros del equipo"
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
{canManageTeam && availableUsers.length > 0 && (
|
{canManageTeam && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowAddForm(true)}
|
onClick={() => setShowAddForm(true)}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export const useTenantStore = create<TenantState>()(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Selectors for common use cases
|
// Selectors for common use cases
|
||||||
|
// Note: For getting tenant ID, prefer using useTenantId() from hooks/useTenantId.ts
|
||||||
export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant);
|
export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant);
|
||||||
export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants);
|
export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants);
|
||||||
export const useCurrentTenantAccess = () => useTenantStore((state) => state.currentTenantAccess);
|
export const useCurrentTenantAccess = () => useTenantStore((state) => state.currentTenantAccess);
|
||||||
|
|||||||
@@ -234,14 +234,14 @@ class EnhancedTenantService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async def update_tenant(
|
async def update_tenant(
|
||||||
self,
|
self,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
update_data: TenantUpdate,
|
update_data: TenantUpdate,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
session: AsyncSession = None
|
session: AsyncSession = None
|
||||||
) -> TenantResponse:
|
) -> TenantResponse:
|
||||||
"""Update tenant information with permission checks"""
|
"""Update tenant information with permission checks"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Verify user has admin access
|
# Verify user has admin access
|
||||||
access = await self.verify_user_access(user_id, tenant_id)
|
access = await self.verify_user_access(user_id, tenant_id)
|
||||||
@@ -250,29 +250,33 @@ class EnhancedTenantService:
|
|||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Insufficient permissions to update tenant"
|
detail="Insufficient permissions to update tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update tenant using repository
|
# Update tenant using repository with proper session management
|
||||||
update_values = update_data.dict(exclude_unset=True)
|
update_values = update_data.dict(exclude_unset=True)
|
||||||
if update_values:
|
if update_values:
|
||||||
updated_tenant = await self.tenant_repo.update(tenant_id, update_values)
|
async with self.database_manager.get_session() as db_session:
|
||||||
|
await self._init_repositories(db_session)
|
||||||
if not updated_tenant:
|
updated_tenant = await self.tenant_repo.update(tenant_id, update_values)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
if not updated_tenant:
|
||||||
detail="Tenant not found"
|
raise HTTPException(
|
||||||
)
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Tenant not found"
|
||||||
logger.info("Tenant updated successfully",
|
)
|
||||||
tenant_id=tenant_id,
|
|
||||||
updated_by=user_id,
|
logger.info("Tenant updated successfully",
|
||||||
fields=list(update_values.keys()))
|
tenant_id=tenant_id,
|
||||||
|
updated_by=user_id,
|
||||||
return TenantResponse.from_orm(updated_tenant)
|
fields=list(update_values.keys()))
|
||||||
|
|
||||||
# No updates to apply
|
return TenantResponse.from_orm(updated_tenant)
|
||||||
tenant = await self.tenant_repo.get_by_id(tenant_id)
|
|
||||||
return TenantResponse.from_orm(tenant)
|
# No updates to apply - get current tenant data
|
||||||
|
async with self.database_manager.get_session() as db_session:
|
||||||
|
await self._init_repositories(db_session)
|
||||||
|
tenant = await self.tenant_repo.get_by_id(tenant_id)
|
||||||
|
return TenantResponse.from_orm(tenant)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user