Add i18 support
This commit is contained in:
@@ -2,6 +2,7 @@ import { Suspense } from 'react';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { ErrorBoundary } from './components/shared/ErrorBoundary';
|
import { ErrorBoundary } from './components/shared/ErrorBoundary';
|
||||||
import { LoadingSpinner } from './components/shared/LoadingSpinner';
|
import { LoadingSpinner } from './components/shared/LoadingSpinner';
|
||||||
@@ -10,7 +11,7 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
|||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { SSEProvider } from './contexts/SSEContext';
|
import { SSEProvider } from './contexts/SSEContext';
|
||||||
import GlobalSubscriptionHandler from './components/auth/GlobalSubscriptionHandler';
|
import GlobalSubscriptionHandler from './components/auth/GlobalSubscriptionHandler';
|
||||||
import './i18n';
|
import i18n from './i18n';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -27,34 +28,36 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter
|
<I18nextProvider i18n={i18n}>
|
||||||
future={{
|
<BrowserRouter
|
||||||
v7_startTransition: true,
|
future={{
|
||||||
v7_relativeSplatPath: true,
|
v7_startTransition: true,
|
||||||
}}
|
v7_relativeSplatPath: true,
|
||||||
>
|
}}
|
||||||
<ThemeProvider>
|
>
|
||||||
<AuthProvider>
|
<ThemeProvider>
|
||||||
<SSEProvider>
|
<AuthProvider>
|
||||||
<Suspense fallback={<LoadingSpinner overlay />}>
|
<SSEProvider>
|
||||||
<AppRouter />
|
<Suspense fallback={<LoadingSpinner overlay />}>
|
||||||
<GlobalSubscriptionHandler />
|
<AppRouter />
|
||||||
<Toaster
|
<GlobalSubscriptionHandler />
|
||||||
position="top-right"
|
<Toaster
|
||||||
toastOptions={{
|
position="top-right"
|
||||||
duration: 4000,
|
toastOptions={{
|
||||||
style: {
|
duration: 4000,
|
||||||
background: '#363636',
|
style: {
|
||||||
color: '#fff',
|
background: '#363636',
|
||||||
},
|
color: '#fff',
|
||||||
}}
|
},
|
||||||
/>
|
}}
|
||||||
</Suspense>
|
/>
|
||||||
</SSEProvider>
|
</Suspense>
|
||||||
</AuthProvider>
|
</SSEProvider>
|
||||||
</ThemeProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</ThemeProvider>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
</BrowserRouter>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</I18nextProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ export class TenantService {
|
|||||||
// Context Management (Frontend-only operations)
|
// Context Management (Frontend-only operations)
|
||||||
setCurrentTenant(tenant: TenantResponse): void {
|
setCurrentTenant(tenant: TenantResponse): void {
|
||||||
// Set tenant context in API client
|
// Set tenant context in API client
|
||||||
apiClient.setTenantId(tenant.id);
|
if (tenant && tenant.id) {
|
||||||
|
apiClient.setTenantId(tenant.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCurrentTenant(): void {
|
clearCurrentTenant(): void {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Button } from '../../ui/Button';
|
import { Button } from '../../ui/Button';
|
||||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||||
import { useAuth } from '../../../contexts/AuthContext';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
import { useUserProgress, useMarkStepCompleted } from '../../../api/hooks/onboarding';
|
import { useUserProgress, useMarkStepCompleted } from '../../../api/hooks/onboarding';
|
||||||
import { useTenantActions } from '../../../stores/tenant.store';
|
import { useTenantActions } from '../../../stores/tenant.store';
|
||||||
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
|
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
|
||||||
import {
|
import {
|
||||||
RegisterTenantStep,
|
RegisterTenantStep,
|
||||||
UploadSalesDataStep,
|
UploadSalesDataStep,
|
||||||
MLTrainingStep,
|
MLTrainingStep,
|
||||||
CompletionStep
|
CompletionStep
|
||||||
} from './steps';
|
} from './steps';
|
||||||
|
import { Building2 } from 'lucide-react';
|
||||||
|
|
||||||
interface StepConfig {
|
interface StepConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,11 +59,26 @@ const STEPS: StepConfig[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const OnboardingWizard: React.FC = () => {
|
export const OnboardingWizard: React.FC = () => {
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
const [searchParams] = useSearchParams();
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Check if this is a fresh onboarding (new tenant creation)
|
||||||
|
const isNewTenant = searchParams.get('new') === 'true';
|
||||||
|
|
||||||
|
// Initialize state based on whether this is a new tenant or not
|
||||||
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(isNewTenant); // If new tenant, consider initialized immediately
|
||||||
|
|
||||||
|
// Debug log for new tenant creation
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNewTenant) {
|
||||||
|
console.log('🆕 New tenant creation detected - UI will reset to step 0');
|
||||||
|
console.log('📊 Current step index:', currentStepIndex);
|
||||||
|
console.log('🎯 Is initialized:', isInitialized);
|
||||||
|
}
|
||||||
|
}, [isNewTenant, currentStepIndex, isInitialized]);
|
||||||
|
|
||||||
// Initialize tenant data for authenticated users
|
// Initialize tenant data for authenticated users
|
||||||
useTenantInitializer();
|
useTenantInitializer();
|
||||||
|
|
||||||
@@ -106,64 +122,77 @@ export const OnboardingWizard: React.FC = () => {
|
|||||||
|
|
||||||
// Initialize step index based on backend progress with validation
|
// Initialize step index based on backend progress with validation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip backend progress loading for new tenant creation
|
||||||
|
if (isNewTenant) {
|
||||||
|
return; // Already initialized to step 0
|
||||||
|
}
|
||||||
|
|
||||||
if (userProgress && !isInitialized) {
|
if (userProgress && !isInitialized) {
|
||||||
console.log('🔄 Initializing onboarding progress:', userProgress);
|
console.log('🔄 Initializing onboarding progress:', userProgress);
|
||||||
|
|
||||||
// Check if user_registered step is completed
|
// Check if user_registered step is completed
|
||||||
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
|
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
|
||||||
if (!userRegisteredStep?.completed) {
|
if (!userRegisteredStep?.completed) {
|
||||||
console.log('⏳ Waiting for user_registered step to be auto-completed...');
|
console.log('⏳ Waiting for user_registered step to be auto-completed...');
|
||||||
return; // Wait for auto-completion to finish
|
return; // Wait for auto-completion to finish
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the current step index based on backend progress
|
let stepIndex = 0; // Default to first step
|
||||||
const currentStepFromBackend = userProgress.current_step;
|
|
||||||
let stepIndex = STEPS.findIndex(step => step.id === currentStepFromBackend);
|
// If this is a new tenant creation, always start from the beginning
|
||||||
|
if (isNewTenant) {
|
||||||
console.log(`🎯 Backend current step: "${currentStepFromBackend}", found at index: ${stepIndex}`);
|
console.log('🆕 New tenant creation - starting from first step');
|
||||||
|
stepIndex = 0;
|
||||||
// If current step is not found (e.g., suppliers step), find the next incomplete step
|
} else {
|
||||||
if (stepIndex === -1) {
|
// Find the current step index based on backend progress
|
||||||
console.log('🔍 Current step not found in UI steps, finding first incomplete step...');
|
const currentStepFromBackend = userProgress.current_step;
|
||||||
|
stepIndex = STEPS.findIndex(step => step.id === currentStepFromBackend);
|
||||||
// Find the first incomplete step that user can access
|
|
||||||
for (let i = 0; i < STEPS.length; i++) {
|
console.log(`🎯 Backend current step: "${currentStepFromBackend}", found at index: ${stepIndex}`);
|
||||||
const step = STEPS[i];
|
|
||||||
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
|
// If current step is not found (e.g., suppliers step), find the next incomplete step
|
||||||
|
if (stepIndex === -1) {
|
||||||
if (!stepProgress?.completed) {
|
console.log('🔍 Current step not found in UI steps, finding first incomplete step...');
|
||||||
stepIndex = i;
|
|
||||||
console.log(`📍 Found first incomplete step: "${step.id}" at index ${i}`);
|
// Find the first incomplete step that user can access
|
||||||
break;
|
for (let i = 0; i < STEPS.length; i++) {
|
||||||
|
const step = STEPS[i];
|
||||||
|
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
|
||||||
|
|
||||||
|
if (!stepProgress?.completed) {
|
||||||
|
stepIndex = i;
|
||||||
|
console.log(`📍 Found first incomplete step: "${step.id}" at index ${i}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all visible steps are completed, go to last step
|
||||||
|
if (stepIndex === -1) {
|
||||||
|
stepIndex = STEPS.length - 1;
|
||||||
|
console.log('✅ All steps completed, going to last step');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If all visible steps are completed, go to last step
|
// Ensure user can't skip ahead - find the first incomplete step
|
||||||
if (stepIndex === -1) {
|
const firstIncompleteStepIndex = STEPS.findIndex(step => {
|
||||||
stepIndex = STEPS.length - 1;
|
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
|
||||||
console.log('✅ All steps completed, going to last step');
|
return !stepProgress?.completed;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (firstIncompleteStepIndex !== -1 && stepIndex > firstIncompleteStepIndex) {
|
||||||
|
console.log(`🚫 User trying to skip ahead. Redirecting to first incomplete step at index ${firstIncompleteStepIndex}`);
|
||||||
|
stepIndex = firstIncompleteStepIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user can't skip ahead - find the first incomplete step
|
|
||||||
const firstIncompleteStepIndex = STEPS.findIndex(step => {
|
|
||||||
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
|
|
||||||
return !stepProgress?.completed;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (firstIncompleteStepIndex !== -1 && stepIndex > firstIncompleteStepIndex) {
|
|
||||||
console.log(`🚫 User trying to skip ahead. Redirecting to first incomplete step at index ${firstIncompleteStepIndex}`);
|
|
||||||
stepIndex = firstIncompleteStepIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🎯 Final step index: ${stepIndex} ("${STEPS[stepIndex]?.id}")`);
|
console.log(`🎯 Final step index: ${stepIndex} ("${STEPS[stepIndex]?.id}")`);
|
||||||
|
|
||||||
if (stepIndex !== currentStepIndex) {
|
if (stepIndex !== currentStepIndex) {
|
||||||
setCurrentStepIndex(stepIndex);
|
setCurrentStepIndex(stepIndex);
|
||||||
}
|
}
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
}, [userProgress, isInitialized, currentStepIndex]);
|
}, [userProgress, isInitialized, currentStepIndex, isNewTenant]);
|
||||||
|
|
||||||
const currentStep = STEPS[currentStepIndex];
|
const currentStep = STEPS[currentStepIndex];
|
||||||
|
|
||||||
@@ -214,7 +243,13 @@ export const OnboardingWizard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentStep.id === 'completion') {
|
if (currentStep.id === 'completion') {
|
||||||
navigate('/app');
|
// Navigate to dashboard after completion
|
||||||
|
if (isNewTenant) {
|
||||||
|
// For new tenant creation, navigate to dashboard and remove the new param
|
||||||
|
navigate('/app/dashboard');
|
||||||
|
} else {
|
||||||
|
navigate('/app');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Auto-advance to next step after successful completion
|
// Auto-advance to next step after successful completion
|
||||||
if (currentStepIndex < STEPS.length - 1) {
|
if (currentStepIndex < STEPS.length - 1) {
|
||||||
@@ -246,8 +281,8 @@ export const OnboardingWizard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading state while initializing progress
|
// Show loading state while initializing progress (skip for new tenant)
|
||||||
if (isLoadingProgress || !isInitialized) {
|
if (!isNewTenant && (isLoadingProgress || !isInitialized)) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
||||||
<Card padding="lg" shadow="lg">
|
<Card padding="lg" shadow="lg">
|
||||||
@@ -262,8 +297,8 @@ export const OnboardingWizard: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error state if progress fails to load
|
// Show error state if progress fails to load (skip for new tenant)
|
||||||
if (progressError) {
|
if (!isNewTenant && progressError) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6">
|
||||||
<Card padding="lg" shadow="lg">
|
<Card padding="lg" shadow="lg">
|
||||||
@@ -297,19 +332,47 @@ export const OnboardingWizard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const StepComponent = currentStep.component;
|
const StepComponent = currentStep.component;
|
||||||
const progressPercentage = userProgress?.completion_percentage || ((currentStepIndex + 1) / STEPS.length) * 100;
|
|
||||||
|
// Calculate progress percentage - reset for new tenant creation
|
||||||
|
const progressPercentage = isNewTenant
|
||||||
|
? ((currentStepIndex + 1) / STEPS.length) * 100 // For new tenant, base progress only on current step
|
||||||
|
: userProgress?.completion_percentage || ((currentStepIndex + 1) / STEPS.length) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 space-y-4 sm:space-y-6 pb-6">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 space-y-4 sm:space-y-6 pb-6">
|
||||||
|
{/* New Tenant Info Banner */}
|
||||||
|
{isNewTenant && (
|
||||||
|
<Card className="bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 border-[var(--color-primary)]/20">
|
||||||
|
<CardBody className="py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||||
|
<Building2 className="w-4 h-4 text-[var(--color-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
Creando Nueva Organización
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Configurarás una nueva panadería desde cero. Este proceso es independiente de tus organizaciones existentes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Enhanced Progress Header */}
|
{/* Enhanced Progress Header */}
|
||||||
<Card shadow="sm" padding="lg">
|
<Card shadow="sm" padding="lg">
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 space-y-2 sm:space-y-0">
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 space-y-2 sm:space-y-0">
|
||||||
<div className="text-center sm:text-left">
|
<div className="text-center sm:text-left">
|
||||||
<h1 className="text-xl sm:text-2xl font-bold text-[var(--text-primary)]">
|
<h1 className="text-xl sm:text-2xl font-bold text-[var(--text-primary)]">
|
||||||
Bienvenido a Bakery IA
|
{isNewTenant ? 'Crear Nueva Organización' : 'Bienvenido a Bakery IA'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
|
<p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
|
||||||
Configura tu sistema de gestión inteligente paso a paso
|
{isNewTenant
|
||||||
|
? 'Configura tu nueva panadería desde cero'
|
||||||
|
: 'Configura tu sistema de gestión inteligente paso a paso'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center sm:text-right">
|
<div className="text-center sm:text-right">
|
||||||
@@ -318,6 +381,7 @@ export const OnboardingWizard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
<div className="text-xs text-[var(--text-tertiary)]">
|
||||||
{Math.round(progressPercentage)}% completado
|
{Math.round(progressPercentage)}% completado
|
||||||
|
{isNewTenant && <span className="text-[var(--color-primary)] ml-1">(nuevo)</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +398,10 @@ export const OnboardingWizard: React.FC = () => {
|
|||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<div className="flex space-x-4 overflow-x-auto pb-2 px-1">
|
<div className="flex space-x-4 overflow-x-auto pb-2 px-1">
|
||||||
{STEPS.map((step, index) => {
|
{STEPS.map((step, index) => {
|
||||||
const isCompleted = userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
|
// For new tenant creation, only show completed if index is less than current step
|
||||||
|
const isCompleted = isNewTenant
|
||||||
|
? index < currentStepIndex
|
||||||
|
: userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
|
||||||
const isCurrent = index === currentStepIndex;
|
const isCurrent = index === currentStepIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -377,7 +444,10 @@ export const OnboardingWizard: React.FC = () => {
|
|||||||
{/* Desktop Step Indicators */}
|
{/* Desktop Step Indicators */}
|
||||||
<div className="hidden sm:flex sm:justify-between">
|
<div className="hidden sm:flex sm:justify-between">
|
||||||
{STEPS.map((step, index) => {
|
{STEPS.map((step, index) => {
|
||||||
const isCompleted = userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
|
// For new tenant creation, only show completed if index is less than current step
|
||||||
|
const isCompleted = isNewTenant
|
||||||
|
? index < currentStepIndex
|
||||||
|
: userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
|
||||||
const isCurrent = index === currentStepIndex;
|
const isCurrent = index === currentStepIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import React, { useState, useCallback, forwardRef } from 'react';
|
import React, { useState, useCallback, forwardRef } from 'react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { useNotifications } from '../../../hooks/useNotifications';
|
import { useNotifications } from '../../../hooks/useNotifications';
|
||||||
import { Button } from '../../ui';
|
import { Button } from '../../ui';
|
||||||
import { Avatar } from '../../ui';
|
|
||||||
import { Badge } from '../../ui';
|
import { Badge } from '../../ui';
|
||||||
import { Modal } from '../../ui';
|
|
||||||
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
||||||
import { ThemeToggle } from '../../ui/ThemeToggle';
|
import { ThemeToggle } from '../../ui/ThemeToggle';
|
||||||
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
|
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
|
||||||
|
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
Bell,
|
Bell,
|
||||||
Settings,
|
|
||||||
User,
|
|
||||||
LogOut,
|
|
||||||
X
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -43,10 +40,6 @@ export interface HeaderProps {
|
|||||||
* Show/hide theme toggle
|
* Show/hide theme toggle
|
||||||
*/
|
*/
|
||||||
showThemeToggle?: boolean;
|
showThemeToggle?: boolean;
|
||||||
/**
|
|
||||||
* Show/hide user menu
|
|
||||||
*/
|
|
||||||
showUserMenu?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Custom logo component
|
* Custom logo component
|
||||||
*/
|
*/
|
||||||
@@ -67,17 +60,14 @@ export interface HeaderProps {
|
|||||||
|
|
||||||
export interface HeaderRef {
|
export interface HeaderRef {
|
||||||
focusSearch: () => void;
|
focusSearch: () => void;
|
||||||
toggleUserMenu: () => void;
|
|
||||||
closeUserMenu: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header - Top navigation header with logo, user menu, notifications, theme toggle
|
* Header - Top navigation header with logo, notifications, theme toggle
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Logo/brand area with responsive sizing
|
* - Logo/brand area with responsive sizing
|
||||||
* - Global search functionality with keyboard shortcuts
|
* - Global search functionality with keyboard shortcuts
|
||||||
* - User avatar with dropdown menu
|
|
||||||
* - Notifications bell with badge count
|
* - Notifications bell with badge count
|
||||||
* - Theme toggle button (light/dark/system)
|
* - Theme toggle button (light/dark/system)
|
||||||
* - Mobile hamburger menu integration
|
* - Mobile hamburger menu integration
|
||||||
@@ -90,16 +80,15 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
showSearch = true,
|
showSearch = true,
|
||||||
showNotifications = true,
|
showNotifications = true,
|
||||||
showThemeToggle = true,
|
showThemeToggle = true,
|
||||||
showUserMenu = true,
|
|
||||||
logo,
|
logo,
|
||||||
searchPlaceholder = 'Buscar...',
|
searchPlaceholder,
|
||||||
notificationCount = 0,
|
notificationCount = 0,
|
||||||
onNotificationClick,
|
onNotificationClick,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
const isAuthenticated = useIsAuthenticated();
|
const isAuthenticated = useIsAuthenticated();
|
||||||
const { logout } = useAuthActions();
|
|
||||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||||
const {
|
const {
|
||||||
notifications,
|
notifications,
|
||||||
@@ -111,34 +100,22 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
clearAll
|
clearAll
|
||||||
} = useNotifications();
|
} = useNotifications();
|
||||||
|
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
||||||
|
|
||||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...');
|
||||||
|
|
||||||
// Focus search input
|
// Focus search input
|
||||||
const focusSearch = useCallback(() => {
|
const focusSearch = useCallback(() => {
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Toggle user menu
|
|
||||||
const toggleUserMenu = useCallback(() => {
|
|
||||||
setIsUserMenuOpen(prev => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close user menu
|
|
||||||
const closeUserMenu = useCallback(() => {
|
|
||||||
setIsUserMenuOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Expose ref methods
|
// Expose ref methods
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
focusSearch,
|
focusSearch,
|
||||||
toggleUserMenu,
|
}), [focusSearch]);
|
||||||
closeUserMenu,
|
|
||||||
}), [focusSearch, toggleUserMenu, closeUserMenu]);
|
|
||||||
|
|
||||||
// Handle search
|
// Handle search
|
||||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -158,11 +135,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle logout
|
|
||||||
const handleLogout = useCallback(async () => {
|
|
||||||
await logout();
|
|
||||||
setIsUserMenuOpen(false);
|
|
||||||
}, [logout]);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -175,7 +147,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);
|
|
||||||
setIsNotificationPanelOpen(false);
|
setIsNotificationPanelOpen(false);
|
||||||
if (isSearchFocused) {
|
if (isSearchFocused) {
|
||||||
searchInputRef.current?.blur();
|
searchInputRef.current?.blur();
|
||||||
@@ -191,9 +162,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const target = event.target as Element;
|
const target = event.target as Element;
|
||||||
if (!target.closest('[data-user-menu]')) {
|
|
||||||
setIsUserMenuOpen(false);
|
|
||||||
}
|
|
||||||
if (!target.closest('[data-notification-panel]')) {
|
if (!target.closest('[data-notification-panel]')) {
|
||||||
setIsNotificationPanelOpen(false);
|
setIsNotificationPanelOpen(false);
|
||||||
}
|
}
|
||||||
@@ -287,7 +255,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
onFocus={() => setIsSearchFocused(true)}
|
onFocus={() => setIsSearchFocused(true)}
|
||||||
onBlur={() => setIsSearchFocused(false)}
|
onBlur={() => setIsSearchFocused(false)}
|
||||||
placeholder={searchPlaceholder}
|
placeholder={defaultSearchPlaceholder}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full pl-10 pr-12 py-2.5 text-sm',
|
'w-full pl-10 pr-12 py-2.5 text-sm',
|
||||||
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
|
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
|
||||||
@@ -297,14 +265,14 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
'placeholder:text-[var(--text-tertiary)]',
|
'placeholder:text-[var(--text-tertiary)]',
|
||||||
'h-9'
|
'h-9'
|
||||||
)}
|
)}
|
||||||
aria-label="Buscar en la aplicación"
|
aria-label={t('common:accessibility.search', 'Search in the application')}
|
||||||
/>
|
/>
|
||||||
{searchValue ? (
|
{searchValue ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearSearch}
|
onClick={clearSearch}
|
||||||
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
|
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
|
||||||
aria-label="Limpiar búsqueda"
|
aria-label={t('common:actions.clear', 'Clear search')}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
|
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -330,12 +298,15 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={focusSearch}
|
onClick={focusSearch}
|
||||||
className="md:hidden w-10 h-10 p-0 flex items-center justify-center"
|
className="md:hidden w-10 h-10 p-0 flex items-center justify-center"
|
||||||
aria-label="Buscar"
|
aria-label={t('common:actions.search', 'Search')}
|
||||||
>
|
>
|
||||||
<Search className="h-5 w-5" />
|
<Search className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Language selector */}
|
||||||
|
<CompactLanguageSelector className="w-auto min-w-[60px]" />
|
||||||
|
|
||||||
{/* Theme toggle */}
|
{/* Theme toggle */}
|
||||||
{showThemeToggle && (
|
{showThemeToggle && (
|
||||||
<ThemeToggle variant="button" size="md" />
|
<ThemeToggle variant="button" size="md" />
|
||||||
@@ -353,8 +324,8 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
!isConnected && "opacity-50",
|
!isConnected && "opacity-50",
|
||||||
isNotificationPanelOpen && "bg-[var(--bg-secondary)]"
|
isNotificationPanelOpen && "bg-[var(--bg-secondary)]"
|
||||||
)}
|
)}
|
||||||
aria-label={`Notificaciones${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ' - Desconectado' : ''}`}
|
aria-label={`${t('common:navigation.notifications', 'Notifications')}${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ` - ${t('common:status.disconnected', 'Disconnected')}` : ''}`}
|
||||||
title={!isConnected ? 'Sin conexión en tiempo real' : undefined}
|
title={!isConnected ? t('common:status.no_realtime_connection', 'No real-time connection') : undefined}
|
||||||
aria-expanded={isNotificationPanelOpen}
|
aria-expanded={isNotificationPanelOpen}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
@@ -385,93 +356,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User menu */}
|
|
||||||
{showUserMenu && user && (
|
|
||||||
<div className="relative" data-user-menu>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={toggleUserMenu}
|
|
||||||
className={clsx(
|
|
||||||
"flex items-center gap-2 pl-2 pr-3 py-1.5 h-9 min-w-0 rounded-lg",
|
|
||||||
"hover:bg-[var(--bg-secondary)] hover:ring-2 hover:ring-[var(--color-primary)]/20",
|
|
||||||
"transition-all duration-200 ease-in-out",
|
|
||||||
"active:scale-95",
|
|
||||||
isUserMenuOpen && "bg-[var(--bg-secondary)] ring-2 ring-[var(--color-primary)]/20"
|
|
||||||
)}
|
|
||||||
aria-label="Menú de usuario"
|
|
||||||
aria-expanded={isUserMenuOpen}
|
|
||||||
aria-haspopup="true"
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
src={user.avatar}
|
|
||||||
alt={user.name}
|
|
||||||
name={user.name}
|
|
||||||
size="xs"
|
|
||||||
className={clsx(
|
|
||||||
"flex-shrink-0 transition-all duration-200",
|
|
||||||
"hover:ring-2 hover:ring-[var(--color-primary)]/30",
|
|
||||||
isUserMenuOpen && "ring-2 ring-[var(--color-primary)]/30"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{isUserMenuOpen && (
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-56 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2 z-[var(--z-dropdown)]">
|
|
||||||
{/* User info */}
|
|
||||||
<div className="px-4 py-3 border-b border-[var(--border-primary)] flex items-center gap-3">
|
|
||||||
<Avatar
|
|
||||||
src={user.avatar || undefined}
|
|
||||||
alt={user.name}
|
|
||||||
name={user.name}
|
|
||||||
size="sm"
|
|
||||||
className="flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
|
||||||
{user.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Menu items */}
|
|
||||||
<div className="py-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/app/settings/profile');
|
|
||||||
setIsUserMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
|
||||||
>
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
Perfil
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/app/settings');
|
|
||||||
setIsUserMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
Configuración
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<div className="border-t border-[var(--border-primary)] pt-1">
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors text-[var(--color-error)]"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
Cerrar Sesión
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React, { useState, useCallback, forwardRef, useMemo } from 'react';
|
import React, { useState, useCallback, forwardRef, useMemo } from 'react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||||
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
||||||
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
||||||
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
||||||
import { Button } from '../../ui';
|
import { Button } from '../../ui';
|
||||||
import { Badge } from '../../ui';
|
import { Badge } from '../../ui';
|
||||||
import { Tooltip } from '../../ui';
|
import { Tooltip } from '../../ui';
|
||||||
|
import { Avatar } from '../../ui';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Package,
|
Package,
|
||||||
@@ -28,7 +30,10 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Dot,
|
Dot,
|
||||||
Menu
|
Menu,
|
||||||
|
LogOut,
|
||||||
|
MoreHorizontal,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
@@ -127,36 +132,71 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
showCollapseButton = true,
|
showCollapseButton = true,
|
||||||
showFooter = true,
|
showFooter = true,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
const isAuthenticated = useIsAuthenticated();
|
const isAuthenticated = useIsAuthenticated();
|
||||||
const currentTenantAccess = useCurrentTenantAccess();
|
const currentTenantAccess = useCurrentTenantAccess();
|
||||||
|
const { logout } = useAuthActions();
|
||||||
|
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||||
|
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Get subscription-aware navigation routes
|
// Get subscription-aware navigation routes
|
||||||
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
|
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
|
||||||
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
|
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
|
||||||
|
|
||||||
|
// Map route paths to translation keys
|
||||||
|
const getTranslationKey = (routePath: string): string => {
|
||||||
|
const pathMappings: Record<string, string> = {
|
||||||
|
'/app/dashboard': 'navigation.dashboard',
|
||||||
|
'/app/operations': 'navigation.operations',
|
||||||
|
'/app/operations/procurement': 'navigation.procurement',
|
||||||
|
'/app/operations/production': 'navigation.production',
|
||||||
|
'/app/operations/pos': 'navigation.pos',
|
||||||
|
'/app/bakery': 'navigation.bakery',
|
||||||
|
'/app/bakery/recipes': 'navigation.recipes',
|
||||||
|
'/app/database': 'navigation.data',
|
||||||
|
'/app/database/inventory': 'navigation.inventory',
|
||||||
|
'/app/analytics': 'navigation.analytics',
|
||||||
|
'/app/analytics/forecasting': 'navigation.forecasting',
|
||||||
|
'/app/analytics/sales': 'navigation.sales',
|
||||||
|
'/app/analytics/performance': 'navigation.performance',
|
||||||
|
'/app/ai': 'navigation.insights',
|
||||||
|
'/app/communications': 'navigation.communications',
|
||||||
|
'/app/communications/notifications': 'navigation.notifications',
|
||||||
|
'/app/communications/alerts': 'navigation.alerts',
|
||||||
|
};
|
||||||
|
|
||||||
|
return pathMappings[routePath] || routePath;
|
||||||
|
};
|
||||||
|
|
||||||
// Convert routes to navigation items - memoized
|
// Convert routes to navigation items - memoized
|
||||||
const navigationItems = useMemo(() => {
|
const navigationItems = useMemo(() => {
|
||||||
const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): NavigationItem[] => {
|
const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): NavigationItem[] => {
|
||||||
return routes.map(route => ({
|
return routes.map(route => {
|
||||||
id: route.path,
|
const translationKey = getTranslationKey(route.path);
|
||||||
label: route.title,
|
const label = translationKey.startsWith('navigation.')
|
||||||
path: route.path,
|
? t(`common:${translationKey}`, route.title)
|
||||||
icon: route.icon ? iconMap[route.icon] : undefined,
|
: route.title;
|
||||||
requiredPermissions: route.requiredPermissions,
|
|
||||||
requiredRoles: route.requiredRoles,
|
return {
|
||||||
children: route.children ? convertRoutesToItems(route.children) : undefined,
|
id: route.path,
|
||||||
}));
|
label,
|
||||||
|
path: route.path,
|
||||||
|
icon: route.icon ? iconMap[route.icon] : undefined,
|
||||||
|
requiredPermissions: route.requiredPermissions,
|
||||||
|
requiredRoles: route.requiredRoles,
|
||||||
|
children: route.children ? convertRoutesToItems(route.children) : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return customItems || convertRoutesToItems(subscriptionFilteredRoutes);
|
return customItems || convertRoutesToItems(subscriptionFilteredRoutes);
|
||||||
}, [customItems, subscriptionFilteredRoutes]);
|
}, [customItems, subscriptionFilteredRoutes, t]);
|
||||||
|
|
||||||
// Filter items based on user permissions - memoized to prevent infinite re-renders
|
// Filter items based on user permissions - memoized to prevent infinite re-renders
|
||||||
const visibleItems = useMemo(() => {
|
const visibleItems = useMemo(() => {
|
||||||
@@ -219,6 +259,17 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
}
|
}
|
||||||
}, [navigate, onClose]);
|
}, [navigate, onClose]);
|
||||||
|
|
||||||
|
// Handle logout
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
await logout();
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
|
// Handle profile menu toggle
|
||||||
|
const handleProfileMenuToggle = useCallback(() => {
|
||||||
|
setIsProfileMenuOpen(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Scroll to item
|
// Scroll to item
|
||||||
const scrollToItem = useCallback((path: string) => {
|
const scrollToItem = useCallback((path: string) => {
|
||||||
const element = sidebarRef.current?.querySelector(`[data-path="${path}"]`);
|
const element = sidebarRef.current?.querySelector(`[data-path="${path}"]`);
|
||||||
@@ -277,8 +328,12 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
// Handle keyboard navigation and touch gestures
|
// Handle keyboard navigation and touch gestures
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && isOpen && onClose) {
|
if (e.key === 'Escape') {
|
||||||
onClose();
|
if (isProfileMenuOpen) {
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
} else if (isOpen && onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -310,13 +365,28 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
document.addEventListener('touchmove', handleTouchMove, { passive: true });
|
document.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
document.removeEventListener('touchstart', handleTouchStart);
|
document.removeEventListener('touchstart', handleTouchStart);
|
||||||
document.removeEventListener('touchmove', handleTouchMove);
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose, isProfileMenuOpen]);
|
||||||
|
|
||||||
|
// Handle click outside profile menu
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (!target.closest('[data-profile-menu]')) {
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isProfileMenuOpen) {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isProfileMenuOpen]);
|
||||||
|
|
||||||
// Render submenu overlay for collapsed sidebar
|
// Render submenu overlay for collapsed sidebar
|
||||||
const renderSubmenuOverlay = (item: NavigationItem) => {
|
const renderSubmenuOverlay = (item: NavigationItem) => {
|
||||||
@@ -383,14 +453,14 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{ItemIcon && (
|
{ItemIcon && (
|
||||||
<ItemIcon
|
<ItemIcon
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex-shrink-0 transition-colors duration-200',
|
'flex-shrink-0 transition-colors duration-200',
|
||||||
isCollapsed ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
isCollapsed && level === 0 ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
||||||
isActive
|
isActive
|
||||||
? 'text-[var(--color-primary)]'
|
? 'text-[var(--color-primary)]'
|
||||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -466,7 +536,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
isActive && 'bg-[var(--color-primary)]/10 border-l-2 border-[var(--color-primary)]',
|
isActive && 'bg-[var(--color-primary)]/10 border-l-2 border-[var(--color-primary)]',
|
||||||
!isActive && 'hover:bg-[var(--bg-secondary)]',
|
!isActive && 'hover:bg-[var(--bg-secondary)]',
|
||||||
item.disabled && 'opacity-50 cursor-not-allowed',
|
item.disabled && 'opacity-50 cursor-not-allowed',
|
||||||
isCollapsed && !hasChildren ? 'flex justify-center items-center p-2 mx-1' : 'p-3'
|
isCollapsed && level === 0 ? 'flex justify-center items-center p-3 aspect-square' : 'p-3'
|
||||||
)}
|
)}
|
||||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
@@ -524,15 +594,99 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
isCollapsed ? 'w-[var(--sidebar-collapsed-width)]' : 'w-[var(--sidebar-width)]',
|
isCollapsed ? 'w-[var(--sidebar-collapsed-width)]' : 'w-[var(--sidebar-width)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Navegación principal"
|
aria-label={t('common:accessibility.menu', 'Main navigation')}
|
||||||
>
|
>
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-1 py-4' : 'p-4')}>
|
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-2 py-4' : 'p-4')}>
|
||||||
<ul className={clsx(isCollapsed ? 'space-y-1' : 'space-y-2')}>
|
<ul className={clsx(isCollapsed ? 'space-y-2' : 'space-y-2')}>
|
||||||
{visibleItems.map(item => renderItem(item))}
|
{visibleItems.map(item => renderItem(item))}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Profile section */}
|
||||||
|
{user && (
|
||||||
|
<div className="border-t border-[var(--border-primary)]">
|
||||||
|
<div className="relative" data-profile-menu>
|
||||||
|
<button
|
||||||
|
onClick={handleProfileMenuToggle}
|
||||||
|
className={clsx(
|
||||||
|
'w-full flex items-center transition-all duration-200',
|
||||||
|
'hover:bg-[var(--bg-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||||
|
isCollapsed ? 'justify-center p-3' : 'p-4 gap-3',
|
||||||
|
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
|
||||||
|
)}
|
||||||
|
aria-label="Menú de perfil"
|
||||||
|
aria-expanded={isProfileMenuOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.name}
|
||||||
|
name={user.name}
|
||||||
|
size={isCollapsed ? "sm" : "md"}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 min-w-0 text-left">
|
||||||
|
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||||
|
{user.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MoreHorizontal className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Profile menu dropdown */}
|
||||||
|
{isProfileMenuOpen && (
|
||||||
|
<div className={clsx(
|
||||||
|
'absolute bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2 z-[var(--z-dropdown)]',
|
||||||
|
isCollapsed ? 'left-full bottom-0 ml-2 min-w-[200px]' : 'left-4 right-4 bottom-full mb-2'
|
||||||
|
)}>
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/app/settings/profile');
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
if (onClose) onClose();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Perfil
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/app/settings');
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
if (onClose) onClose();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Configuración
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--border-primary)] pt-1">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors text-[var(--color-error)]"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Cerrar Sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Collapse button */}
|
{/* Collapse button */}
|
||||||
{showCollapseButton && (
|
{showCollapseButton && (
|
||||||
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed ? 'p-2' : 'p-4')}>
|
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed ? 'p-2' : 'p-4')}>
|
||||||
@@ -541,60 +695,57 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full flex items-center justify-center',
|
'w-full flex items-center transition-colors duration-200',
|
||||||
isCollapsed ? 'p-2' : 'px-4 py-2'
|
isCollapsed ? 'justify-center p-3 aspect-square' : 'justify-start px-4 py-2'
|
||||||
)}
|
)}
|
||||||
aria-label={isCollapsed ? 'Expandir sidebar' : 'Contraer sidebar'}
|
aria-label={isCollapsed ? t('common:actions.expand', 'Expand sidebar') : t('common:actions.collapse', 'Collapse sidebar')}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
<span className="text-sm">Contraer</span>
|
<span className="text-sm">{t('common:actions.collapse', 'Collapse')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{showFooter && !isCollapsed && (
|
|
||||||
<div className="p-4 border-t border-[var(--border-primary)]">
|
|
||||||
<div className="text-xs text-[var(--text-tertiary)] text-center">
|
|
||||||
Panadería IA v2.0.0
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile Drawer */}
|
{/* Mobile Drawer */}
|
||||||
<aside
|
<aside
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[85vw]',
|
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[90vw]',
|
||||||
'bg-[var(--bg-primary)] border-r border-[var(--border-primary)]',
|
'bg-[var(--bg-primary)] border-r border-[var(--border-primary)]',
|
||||||
'transition-transform duration-300 ease-in-out z-[var(--z-modal)]',
|
'transition-transform duration-300 ease-in-out z-[var(--z-modal)]',
|
||||||
'lg:hidden flex flex-col',
|
'lg:hidden flex flex-col',
|
||||||
'shadow-xl',
|
'shadow-2xl backdrop-blur-sm',
|
||||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
)}
|
)}
|
||||||
aria-label="Navegación principal"
|
aria-label={t('common:accessibility.menu', 'Main navigation')}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
|
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
|
||||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
<div className="flex items-center gap-3">
|
||||||
Navegación
|
<div className="w-8 h-8 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
|
||||||
</h2>
|
PI
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
Panadería IA
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2"
|
className="p-2 hover:bg-[var(--bg-secondary)]"
|
||||||
aria-label="Cerrar navegación"
|
aria-label="Cerrar navegación"
|
||||||
>
|
>
|
||||||
<Menu className="w-4 h-4" />
|
<X className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -605,14 +756,82 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Profile section - Mobile */}
|
||||||
{showFooter && (
|
{user && (
|
||||||
<div className="p-4 border-t border-[var(--border-primary)]">
|
<div className="border-t border-[var(--border-primary)]">
|
||||||
<div className="text-xs text-[var(--text-tertiary)] text-center">
|
<div className="relative" data-profile-menu>
|
||||||
Panadería IA v2.0.0
|
<button
|
||||||
|
onClick={handleProfileMenuToggle}
|
||||||
|
className={clsx(
|
||||||
|
'w-full flex items-center p-4 gap-3 transition-all duration-200',
|
||||||
|
'hover:bg-[var(--bg-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||||
|
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
|
||||||
|
)}
|
||||||
|
aria-label="Menú de perfil"
|
||||||
|
aria-expanded={isProfileMenuOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.name}
|
||||||
|
name={user.name}
|
||||||
|
size="md"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 text-left">
|
||||||
|
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||||
|
{user.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MoreHorizontal className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Profile menu dropdown - Mobile */}
|
||||||
|
{isProfileMenuOpen && (
|
||||||
|
<div className="mx-4 mb-2 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg py-2">
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/app/settings/profile');
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
if (onClose) onClose();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Perfil
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/app/settings');
|
||||||
|
setIsProfileMenuOpen(false);
|
||||||
|
if (onClose) onClose();
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Configuración
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--border-primary)] pt-1">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors text-[var(--color-error)]"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Cerrar Sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
78
frontend/src/components/ui/LanguageSelector.tsx
Normal file
78
frontend/src/components/ui/LanguageSelector.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
languageConfig,
|
||||||
|
supportedLanguages,
|
||||||
|
type SupportedLanguage
|
||||||
|
} from '../../locales';
|
||||||
|
import { useLanguageSwitcher } from '../../hooks/useLanguageSwitcher';
|
||||||
|
import { Select } from './Select';
|
||||||
|
|
||||||
|
interface LanguageSelectorProps {
|
||||||
|
className?: string;
|
||||||
|
variant?: 'compact' | 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSelector({
|
||||||
|
className,
|
||||||
|
variant = 'full'
|
||||||
|
}: LanguageSelectorProps) {
|
||||||
|
const { currentLanguage, changeLanguage, isChanging } = useLanguageSwitcher();
|
||||||
|
|
||||||
|
const languageOptions = supportedLanguages.map(lang => ({
|
||||||
|
value: lang,
|
||||||
|
label: variant === 'compact'
|
||||||
|
? `${languageConfig[lang].flag} ${languageConfig[lang].code.toUpperCase()}`
|
||||||
|
: `${languageConfig[lang].flag} ${languageConfig[lang].nativeName}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleLanguageChange = (value: string | number | Array<string | number>) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const newLanguage = value as SupportedLanguage;
|
||||||
|
changeLanguage(newLanguage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={currentLanguage}
|
||||||
|
onChange={handleLanguageChange}
|
||||||
|
options={languageOptions}
|
||||||
|
className={className}
|
||||||
|
placeholder="Select language"
|
||||||
|
disabled={isChanging}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact version for headers/toolbars
|
||||||
|
export function CompactLanguageSelector({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<LanguageSelector
|
||||||
|
variant="compact"
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for language-related utilities
|
||||||
|
export function useLanguageUtils() {
|
||||||
|
const { currentLanguage, changeLanguage } = useLanguageSwitcher();
|
||||||
|
|
||||||
|
const getCurrentLanguageConfig = () => languageConfig[currentLanguage];
|
||||||
|
|
||||||
|
const isRTL = () => getCurrentLanguageConfig().rtl;
|
||||||
|
|
||||||
|
const getLanguageFlag = () => getCurrentLanguageConfig().flag;
|
||||||
|
|
||||||
|
const getLanguageName = () => getCurrentLanguageConfig().nativeName;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLanguage,
|
||||||
|
languageConfig: getCurrentLanguageConfig(),
|
||||||
|
isRTL,
|
||||||
|
getLanguageFlag,
|
||||||
|
getLanguageName,
|
||||||
|
changeLanguage,
|
||||||
|
availableLanguages: supportedLanguages,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -95,12 +95,15 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const unreadNotifications = notifications.filter(n => !n.read);
|
const unreadNotifications = notifications.filter(n => !n.read);
|
||||||
|
const isMobile = window.innerWidth < 768;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/20"
|
className={`fixed inset-0 z-[9998] transition-all duration-300 ${
|
||||||
|
isMobile ? 'bg-black/50 backdrop-blur-sm' : 'bg-black/20'
|
||||||
|
}`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -108,51 +111,66 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
|||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute right-0 top-full mt-2 w-96 max-w-sm bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl z-50 max-h-96 flex flex-col",
|
"fixed z-[9999] bg-[var(--bg-primary)] border border-[var(--border-primary)] shadow-2xl flex flex-col transition-all duration-300 ease-out",
|
||||||
|
isMobile
|
||||||
|
? "inset-x-0 bottom-0 rounded-t-2xl max-h-[85vh]"
|
||||||
|
: "right-0 top-full mt-2 w-96 max-w-sm rounded-xl shadow-xl max-h-96",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Mobile Handle */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="flex justify-center py-3">
|
||||||
|
<div className="w-10 h-1 bg-[var(--border-secondary)] rounded-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
|
<div className={`flex items-center justify-between border-b border-[var(--border-primary)] ${
|
||||||
|
isMobile ? 'px-6 py-4' : 'px-4 py-3'
|
||||||
|
}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
|
<h3 className={`font-semibold text-[var(--text-primary)] ${
|
||||||
|
isMobile ? 'text-lg' : 'text-sm'
|
||||||
|
}`}>
|
||||||
Notificaciones
|
Notificaciones
|
||||||
</h3>
|
</h3>
|
||||||
{unreadNotifications.length > 0 && (
|
{unreadNotifications.length > 0 && (
|
||||||
<Badge variant="info" size="sm">
|
<Badge variant="info" size={isMobile ? "md" : "sm"}>
|
||||||
{unreadNotifications.length} nuevas
|
{unreadNotifications.length} nuevas
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-2">
|
||||||
{unreadNotifications.length > 0 && (
|
{unreadNotifications.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size={isMobile ? "md" : "sm"}
|
||||||
onClick={onMarkAllAsRead}
|
onClick={onMarkAllAsRead}
|
||||||
className="h-6 px-2 text-xs"
|
className={`${isMobile ? 'px-3 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
|
||||||
>
|
>
|
||||||
|
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||||
Marcar todas
|
Marcar todas
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size={isMobile ? "md" : "sm"}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="h-6 w-6 p-0"
|
className={`${isMobile ? 'p-2' : 'h-6 w-6 p-0'} hover:bg-[var(--bg-secondary)] rounded-full`}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notifications List */}
|
{/* Notifications List */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className={`flex-1 overflow-y-auto ${isMobile ? 'px-2 py-2' : ''}`}>
|
||||||
{notifications.length === 0 ? (
|
{notifications.length === 0 ? (
|
||||||
<div className="p-8 text-center">
|
<div className={`text-center ${isMobile ? 'py-12 px-6' : 'p-8'}`}>
|
||||||
<CheckCircle className="w-8 h-8 mx-auto mb-2" style={{ color: 'var(--color-success)' }} />
|
<CheckCircle className={`mx-auto mb-3 ${isMobile ? 'w-12 h-12' : 'w-8 h-8'}`} style={{ color: 'var(--color-success)' }} />
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<p className={`text-[var(--text-secondary)] ${isMobile ? 'text-base' : 'text-sm'}`}>
|
||||||
No hay notificaciones
|
No hay notificaciones
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,18 +183,19 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-3 hover:bg-[var(--bg-secondary)] transition-colors",
|
"transition-colors hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)]",
|
||||||
|
isMobile ? 'px-4 py-4 mx-2 my-1 rounded-lg' : 'p-3',
|
||||||
!notification.read && "bg-[var(--color-info)]/5"
|
!notification.read && "bg-[var(--color-info)]/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className={`flex gap-${isMobile ? '4' : '3'}`}>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className="flex-shrink-0 p-1 rounded-full mt-0.5"
|
className={`flex-shrink-0 rounded-full mt-0.5 ${isMobile ? 'p-2' : 'p-1'}`}
|
||||||
style={{ backgroundColor: getSeverityColor(notification.severity) + '15' }}
|
style={{ backgroundColor: getSeverityColor(notification.severity) + '15' }}
|
||||||
>
|
>
|
||||||
<SeverityIcon
|
<SeverityIcon
|
||||||
className="w-3 h-3"
|
className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`}
|
||||||
style={{ color: getSeverityColor(notification.severity) }}
|
style={{ color: getSeverityColor(notification.severity) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,50 +203,56 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
<div className={`flex items-start justify-between gap-2 ${isMobile ? 'mb-2' : 'mb-1'}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className={`flex items-center gap-2 ${isMobile ? 'flex-wrap' : ''}`}>
|
||||||
<Badge variant={getSeverityBadge(notification.severity)} size="sm">
|
<Badge variant={getSeverityBadge(notification.severity)} size={isMobile ? "md" : "sm"}>
|
||||||
{notification.severity.toUpperCase()}
|
{notification.severity.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary" size="sm">
|
<Badge variant="secondary" size={isMobile ? "md" : "sm"}>
|
||||||
{notification.item_type === 'alert' ? 'Alerta' : 'Recomendación'}
|
{notification.item_type === 'alert' ? 'Alerta' : 'Recomendación'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
<span className={`font-medium text-[var(--text-secondary)] ${isMobile ? 'text-sm' : 'text-xs'}`}>
|
||||||
{formatTimestamp(notification.timestamp)}
|
{formatTimestamp(notification.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<p className="text-sm font-medium mb-1 leading-tight" style={{ color: 'var(--text-primary)' }}>
|
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
|
||||||
|
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
|
||||||
|
}`}>
|
||||||
{notification.title}
|
{notification.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<p className="text-xs leading-relaxed mb-2" style={{ color: 'var(--text-secondary)' }}>
|
<p className={`leading-relaxed text-[var(--text-secondary)] ${
|
||||||
|
isMobile ? 'text-sm mb-4' : 'text-xs mb-2'
|
||||||
|
}`}>
|
||||||
{notification.message}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-1">
|
<div className={`flex items-center gap-2 ${isMobile ? 'flex-col sm:flex-row' : ''}`}>
|
||||||
{!notification.read && (
|
{!notification.read && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size={isMobile ? "md" : "sm"}
|
||||||
onClick={() => onMarkAsRead(notification.id)}
|
onClick={() => onMarkAsRead(notification.id)}
|
||||||
className="h-6 px-2 text-xs"
|
className={`${isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
|
||||||
>
|
>
|
||||||
<Check className="w-3 h-3 mr-1" />
|
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||||
Marcar como leído
|
Marcar como leído
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size={isMobile ? "md" : "sm"}
|
||||||
onClick={() => onRemoveNotification(notification.id)}
|
onClick={() => onRemoveNotification(notification.id)}
|
||||||
className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
|
className={`text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
|
||||||
|
isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3 mr-1" />
|
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||||
Eliminar
|
Eliminar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,13 +267,18 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<div className="p-3 border-t border-[var(--border-primary)] bg-[var(--bg-secondary)]">
|
<div className={`border-t border-[var(--border-primary)] bg-[var(--bg-secondary)] ${
|
||||||
|
isMobile ? 'px-6 py-4' : 'p-3'
|
||||||
|
}`}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size={isMobile ? "md" : "sm"}
|
||||||
onClick={onClearAll}
|
onClick={onClearAll}
|
||||||
className="w-full text-xs text-red-600 hover:text-red-700"
|
className={`w-full text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
|
||||||
|
isMobile ? 'px-4 py-3 text-sm' : 'text-xs'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
|
||||||
Limpiar todas las notificaciones
|
Limpiar todas las notificaciones
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useNavigate } from 'react-router-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, Plus } from 'lucide-react';
|
import { ChevronDown, Building2, Check, AlertCircle, Plus, X } from 'lucide-react';
|
||||||
|
|
||||||
interface TenantSwitcherProps {
|
interface TenantSwitcherProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -92,41 +92,35 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
// Calculate dropdown position
|
// Calculate dropdown position
|
||||||
const calculateDropdownPosition = () => {
|
const calculateDropdownPosition = () => {
|
||||||
if (!buttonRef.current) return;
|
if (!buttonRef.current) return;
|
||||||
|
|
||||||
const buttonRect = buttonRef.current.getBoundingClientRect();
|
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
const isMobile = viewportWidth < 768; // md breakpoint
|
const isMobile = viewportWidth < 768; // md breakpoint
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
// On mobile, use full width with margins and position from top
|
// On mobile, use a modal-style overlay
|
||||||
// Check if dropdown would go off bottom of screen
|
|
||||||
const dropdownHeight = Math.min(400, viewportHeight * 0.7); // Max 70vh
|
|
||||||
const spaceBelow = viewportHeight - buttonRect.bottom - 8;
|
|
||||||
const shouldPositionAbove = spaceBelow < dropdownHeight && buttonRect.top > dropdownHeight;
|
|
||||||
|
|
||||||
setDropdownPosition({
|
setDropdownPosition({
|
||||||
top: shouldPositionAbove ? buttonRect.top - dropdownHeight - 8 : buttonRect.bottom + 8,
|
top: 0,
|
||||||
left: 16, // 1rem margin from screen edge
|
left: 0,
|
||||||
right: 16, // For full width calculation
|
width: viewportWidth,
|
||||||
width: viewportWidth - 32, // Full width minus margins
|
|
||||||
isMobile: true,
|
isMobile: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Desktop positioning - align right edge of dropdown with right edge of button
|
// Desktop positioning - align right edge of dropdown with right edge of button
|
||||||
const dropdownWidth = 320; // w-80 (20rem * 16px) - slightly wider for desktop
|
const dropdownWidth = 360; // Wider for better content display
|
||||||
let left = buttonRect.right - dropdownWidth;
|
let left = buttonRect.right - dropdownWidth;
|
||||||
|
|
||||||
// Ensure dropdown doesn't go off the left edge of the screen
|
// Ensure dropdown doesn't go off the left edge of the screen
|
||||||
if (left < 16) {
|
if (left < 16) {
|
||||||
left = 16;
|
left = 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure dropdown doesn't go off the right edge
|
// Ensure dropdown doesn't go off the right edge
|
||||||
if (left + dropdownWidth > viewportWidth - 16) {
|
if (left + dropdownWidth > viewportWidth - 16) {
|
||||||
left = viewportWidth - dropdownWidth - 16;
|
left = viewportWidth - dropdownWidth - 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDropdownPosition({
|
setDropdownPosition({
|
||||||
top: buttonRect.bottom + 8,
|
top: buttonRect.bottom + 8,
|
||||||
left: left,
|
left: left,
|
||||||
@@ -175,7 +169,7 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
// Handle creating new tenant
|
// Handle creating new tenant
|
||||||
const handleCreateNewTenant = () => {
|
const handleCreateNewTenant = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
navigate('/app/onboarding');
|
navigate('/app/onboarding?new=true');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't render if no tenants available
|
// Don't render if no tenants available
|
||||||
@@ -200,122 +194,221 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
|||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
onClick={toggleDropdown}
|
onClick={toggleDropdown}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center space-x-2 px-3 py-2 text-sm font-medium text-text-primary bg-bg-secondary hover:bg-bg-tertiary border border-border-secondary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20 disabled:opacity-50 disabled:cursor-not-allowed"
|
className={`
|
||||||
|
flex items-center justify-between w-full
|
||||||
|
${showLabel ? 'px-3 py-2.5' : 'px-2.5 py-2.5 justify-center'}
|
||||||
|
text-sm font-medium text-[var(--text-primary)]
|
||||||
|
bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)]
|
||||||
|
border border-[var(--border-secondary)]
|
||||||
|
rounded-lg transition-all duration-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20
|
||||||
|
active:scale-[0.98] active:bg-[var(--bg-tertiary)]
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
min-h-[44px] touch-manipulation
|
||||||
|
${isOpen ? 'ring-2 ring-[var(--color-primary)]/20 bg-[var(--bg-tertiary)]' : ''}
|
||||||
|
`}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-label="Switch tenant"
|
aria-label="Switch tenant"
|
||||||
>
|
>
|
||||||
<Building2 className="w-4 h-4 text-text-secondary" />
|
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||||
|
<div className={`
|
||||||
|
flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center
|
||||||
|
${currentTenant ? 'bg-[var(--color-primary)]/10' : 'bg-[var(--bg-tertiary)]'}
|
||||||
|
`}>
|
||||||
|
<Building2 className={`w-4 h-4 ${currentTenant ? 'text-[var(--color-primary)]' : 'text-[var(--text-tertiary)]'}`} />
|
||||||
|
</div>
|
||||||
|
{showLabel && (
|
||||||
|
<div className="flex-1 min-w-0 text-left">
|
||||||
|
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||||
|
{currentTenant?.name || 'Select Organization'}
|
||||||
|
</div>
|
||||||
|
{currentTenant?.city && (
|
||||||
|
<div className="text-xs text-[var(--text-secondary)] truncate">
|
||||||
|
{currentTenant.city}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<span className="hidden sm:block max-w-32 truncate">
|
<ChevronDown
|
||||||
{currentTenant?.name || 'Select Tenant'}
|
className={`w-4 h-4 text-[var(--text-secondary)] transition-transform duration-200 flex-shrink-0 ml-2 ${
|
||||||
</span>
|
isOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<ChevronDown
|
|
||||||
className={`w-4 h-4 text-text-secondary transition-transform ${
|
|
||||||
isOpen ? 'rotate-180' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown Menu - Rendered in portal to avoid stacking context issues */}
|
{/* Dropdown Menu - Rendered in portal to avoid stacking context issues */}
|
||||||
{isOpen && createPortal(
|
{isOpen && createPortal(
|
||||||
<div
|
<>
|
||||||
ref={dropdownRef}
|
{/* Mobile Backdrop */}
|
||||||
className={`fixed bg-bg-primary border border-border-secondary rounded-lg shadow-lg z-[9999] ${
|
{dropdownPosition.isMobile && (
|
||||||
dropdownPosition.isMobile ? 'mx-4' : ''
|
<div
|
||||||
}`}
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9998]"
|
||||||
style={{
|
onClick={() => setIsOpen(false)}
|
||||||
top: `${dropdownPosition.top}px`,
|
/>
|
||||||
left: `${dropdownPosition.left}px`,
|
|
||||||
width: `${dropdownPosition.width}px`,
|
|
||||||
maxHeight: dropdownPosition.isMobile ? '70vh' : '80vh',
|
|
||||||
}}
|
|
||||||
role="listbox"
|
|
||||||
aria-label="Available tenants"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<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'}`}>
|
|
||||||
Organizations
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error State */}
|
|
||||||
{error && (
|
|
||||||
<div className="px-3 py-2 border-b border-border-primary">
|
|
||||||
<div className="flex items-center space-x-2 text-color-error text-xs">
|
|
||||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span>{error}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="ml-auto text-color-primary hover:text-color-primary-dark underline"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tenant List */}
|
{/* Dropdown Content */}
|
||||||
<div className={`overflow-y-auto ${dropdownPosition.isMobile ? 'max-h-[60vh]' : 'max-h-80'}`}>
|
<div
|
||||||
{availableTenants.map((tenant) => (
|
ref={dropdownRef}
|
||||||
<button
|
className={`
|
||||||
key={tenant.id}
|
fixed z-[9999] bg-[var(--bg-primary)] border border-[var(--border-primary)]
|
||||||
onClick={() => handleTenantSwitch(tenant.id)}
|
shadow-2xl transition-all duration-300 ease-out
|
||||||
disabled={isLoading}
|
${dropdownPosition.isMobile
|
||||||
className={`w-full text-left hover:bg-bg-secondary focus:bg-bg-secondary focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
? 'inset-x-0 bottom-0 rounded-t-2xl max-h-[85vh]'
|
||||||
dropdownPosition.isMobile ? 'px-4 py-4 active:bg-bg-tertiary' : 'px-3 py-3'
|
: 'rounded-xl shadow-xl'
|
||||||
}`}
|
}
|
||||||
role="option"
|
`}
|
||||||
aria-selected={tenant.id === currentTenant?.id}
|
style={
|
||||||
>
|
dropdownPosition.isMobile
|
||||||
<div className="flex items-center justify-between">
|
? {}
|
||||||
<div className="flex-1 min-w-0">
|
: {
|
||||||
<div className="flex items-center space-x-3">
|
top: `${dropdownPosition.top}px`,
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
left: `${dropdownPosition.left}px`,
|
||||||
tenant.id === currentTenant?.id
|
width: `${dropdownPosition.width}px`,
|
||||||
? 'bg-color-primary text-white'
|
maxHeight: '400px',
|
||||||
: 'bg-color-primary/10 text-color-primary'
|
}
|
||||||
}`}>
|
}
|
||||||
<Building2 className="w-4 h-4" />
|
role="listbox"
|
||||||
</div>
|
aria-label="Available tenants"
|
||||||
<div className="min-w-0 flex-1">
|
>
|
||||||
<p className="text-sm font-medium text-text-primary truncate">
|
{/* Mobile Handle */}
|
||||||
{tenant.name}
|
{dropdownPosition.isMobile && (
|
||||||
</p>
|
<div className="flex justify-center py-3">
|
||||||
<p className="text-xs text-text-secondary truncate">
|
<div className="w-10 h-1 bg-[var(--border-secondary)] rounded-full" />
|
||||||
{tenant.city}
|
</div>
|
||||||
</p>
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`
|
||||||
|
flex items-center justify-between
|
||||||
|
border-b border-[var(--border-primary)]
|
||||||
|
${dropdownPosition.isMobile ? 'px-6 py-4' : 'px-4 py-3'}
|
||||||
|
`}>
|
||||||
|
<h3 className={`font-semibold text-[var(--text-primary)] ${
|
||||||
|
dropdownPosition.isMobile ? 'text-lg' : 'text-sm'
|
||||||
|
}`}>
|
||||||
|
Organizations
|
||||||
|
</h3>
|
||||||
|
{dropdownPosition.isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="p-2 hover:bg-[var(--bg-secondary)] rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-[var(--text-secondary)]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<div className={`border-b border-[var(--border-primary)] ${
|
||||||
|
dropdownPosition.isMobile ? 'px-6 py-4' : 'px-4 py-3'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center space-x-3 text-[var(--color-error)]">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span className="text-sm flex-1">{error}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] underline text-sm font-medium"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tenant List */}
|
||||||
|
<div className={`
|
||||||
|
overflow-y-auto
|
||||||
|
${dropdownPosition.isMobile ? 'max-h-[50vh] px-2 py-2' : 'max-h-72 py-1'}
|
||||||
|
`}>
|
||||||
|
{availableTenants.map((tenant) => (
|
||||||
|
<button
|
||||||
|
key={tenant.id}
|
||||||
|
onClick={() => handleTenantSwitch(tenant.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`
|
||||||
|
w-full text-left rounded-lg transition-all duration-200
|
||||||
|
hover:bg-[var(--bg-secondary)] focus:bg-[var(--bg-secondary)]
|
||||||
|
active:scale-[0.98] active:bg-[var(--bg-tertiary)]
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
${dropdownPosition.isMobile ? 'px-4 py-4 mx-2 my-1 min-h-[60px]' : 'px-3 py-3 mx-1 my-0.5'}
|
||||||
|
${tenant.id === currentTenant?.id ? 'bg-[var(--color-primary)]/5 ring-1 ring-[var(--color-primary)]/20' : ''}
|
||||||
|
`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={tenant.id === currentTenant?.id}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className={`
|
||||||
|
${dropdownPosition.isMobile ? 'w-12 h-12' : 'w-10 h-10'}
|
||||||
|
rounded-full flex items-center justify-center flex-shrink-0 transition-colors
|
||||||
|
${tenant.id === currentTenant?.id
|
||||||
|
? 'bg-[var(--color-primary)] text-white'
|
||||||
|
: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
<Building2 className={`${dropdownPosition.isMobile ? 'w-6 h-6' : 'w-5 h-5'}`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`font-medium text-[var(--text-primary)] truncate ${
|
||||||
|
dropdownPosition.isMobile ? 'text-base' : 'text-sm'
|
||||||
|
}`}>
|
||||||
|
{tenant.name}
|
||||||
|
</p>
|
||||||
|
{tenant.city && (
|
||||||
|
<p className={`text-[var(--text-secondary)] truncate ${
|
||||||
|
dropdownPosition.isMobile ? 'text-sm' : 'text-xs'
|
||||||
|
}`}>
|
||||||
|
{tenant.city}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tenant.id === currentTenant?.id && (
|
||||||
|
<Check className={`text-[var(--color-success)] flex-shrink-0 ml-3 ${
|
||||||
|
dropdownPosition.isMobile ? 'w-6 h-6' : 'w-5 h-5'
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{tenant.id === currentTenant?.id && (
|
{/* Footer */}
|
||||||
<Check className="w-4 h-4 text-color-success flex-shrink-0 ml-2" />
|
<div className={`
|
||||||
)}
|
border-t border-[var(--border-primary)]
|
||||||
</div>
|
${dropdownPosition.isMobile ? 'px-6 py-6' : 'px-4 py-3'}
|
||||||
|
`}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateNewTenant}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center gap-3
|
||||||
|
${dropdownPosition.isMobile ? 'px-6 py-4 text-base' : 'px-4 py-2.5 text-sm'}
|
||||||
|
text-white font-semibold
|
||||||
|
bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)]
|
||||||
|
hover:from-[var(--color-primary-dark)] hover:to-[var(--color-primary)]
|
||||||
|
shadow-md hover:shadow-lg
|
||||||
|
rounded-lg transition-all duration-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/30
|
||||||
|
active:scale-[0.98]
|
||||||
|
border border-[var(--color-primary)]/30
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Plus className={`${dropdownPosition.isMobile ? 'w-5 h-5' : 'w-4 h-4'}`} />
|
||||||
|
<span>Agregar Nueva Organización</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>,
|
||||||
{/* Footer */}
|
|
||||||
<div className={`border-t border-border-primary bg-bg-secondary rounded-b-lg ${
|
|
||||||
dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
|
|
||||||
}`}>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateNewTenant}
|
|
||||||
className={`w-full flex items-center justify-center gap-2 ${
|
|
||||||
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`}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span className={`font-medium ${dropdownPosition.isMobile ? 'text-sm' : 'text-xs'}`}>
|
|
||||||
Add New Organization
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
30
frontend/src/hooks/useLanguageSwitcher.ts
Normal file
30
frontend/src/hooks/useLanguageSwitcher.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useUIStore } from '../stores/ui.store';
|
||||||
|
import { type SupportedLanguage } from '../locales';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing language switching with proper synchronization
|
||||||
|
* between i18n and UI store
|
||||||
|
*/
|
||||||
|
export function useLanguageSwitcher() {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const { language: uiLanguage, setLanguage: setUILanguage } = useUIStore();
|
||||||
|
|
||||||
|
const changeLanguage = useCallback(async (newLanguage: SupportedLanguage) => {
|
||||||
|
try {
|
||||||
|
// Only change i18n language - let the i18n event handler update UI store
|
||||||
|
await i18n.changeLanguage(newLanguage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to change language:', error);
|
||||||
|
}
|
||||||
|
}, [i18n]);
|
||||||
|
|
||||||
|
const isChanging = i18n.isLanguageChangingTo !== false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLanguage: i18n.language as SupportedLanguage,
|
||||||
|
changeLanguage,
|
||||||
|
isChanging,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
// frontend/src/i18n/index.ts
|
// frontend/src/i18n/index.ts
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
import { resources, defaultLanguage } from '../locales';
|
import { resources, defaultLanguage, supportedLanguages } from '../locales';
|
||||||
|
|
||||||
|
// Get saved language from localStorage or default
|
||||||
|
const getSavedLanguage = () => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('ui-storage');
|
||||||
|
if (stored) {
|
||||||
|
const { state } = JSON.parse(stored);
|
||||||
|
if (state?.language && supportedLanguages.includes(state.language)) {
|
||||||
|
console.log(`🔍 Found stored language: ${state.language}`);
|
||||||
|
return state.language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse stored language:', error);
|
||||||
|
}
|
||||||
|
console.log(`📌 Using default language: ${defaultLanguage}`);
|
||||||
|
return defaultLanguage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialLanguage = getSavedLanguage();
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
resources,
|
resources,
|
||||||
lng: defaultLanguage,
|
lng: initialLanguage,
|
||||||
fallbackLng: defaultLanguage,
|
fallbackLng: defaultLanguage,
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
@@ -33,6 +53,29 @@ i18n
|
|||||||
react: {
|
react: {
|
||||||
useSuspense: false,
|
useSuspense: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Return key with namespace if translation is missing
|
||||||
|
saveMissing: process.env.NODE_ENV === 'development',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log(`🚀 i18n initialized with language: ${initialLanguage}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for language changes and update UI store
|
||||||
|
i18n.on('languageChanged', (lng) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Update document direction for RTL languages
|
||||||
|
document.dir = i18n.dir(lng);
|
||||||
|
|
||||||
|
// Update UI store to keep it in sync (without triggering i18n again)
|
||||||
|
import('../stores/ui.store').then(({ useUIStore }) => {
|
||||||
|
const currentLanguage = useUIStore.getState().language;
|
||||||
|
if (currentLanguage !== lng) {
|
||||||
|
// Set directly to avoid recursive calls
|
||||||
|
useUIStore.setState({ language: lng as any });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
240
frontend/src/locales/en/common.json
Normal file
240
frontend/src/locales/en/common.json
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
{
|
||||||
|
"navigation": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"operations": "Operations",
|
||||||
|
"inventory": "Inventory",
|
||||||
|
"production": "Production",
|
||||||
|
"recipes": "Recipes",
|
||||||
|
"orders": "Orders",
|
||||||
|
"procurement": "Procurement",
|
||||||
|
"pos": "Point of Sale",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"forecasting": "Forecasting",
|
||||||
|
"sales": "Sales",
|
||||||
|
"performance": "Performance",
|
||||||
|
"insights": "AI Insights",
|
||||||
|
"data": "Data",
|
||||||
|
"weather": "Weather",
|
||||||
|
"traffic": "Traffic",
|
||||||
|
"events": "Events",
|
||||||
|
"communications": "Communications",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"alerts": "Alerts",
|
||||||
|
"preferences": "Preferences",
|
||||||
|
"settings": "Settings",
|
||||||
|
"team": "Team",
|
||||||
|
"bakery": "Bakery",
|
||||||
|
"training": "Training",
|
||||||
|
"system": "System",
|
||||||
|
"onboarding": "Initial Setup"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"add": "Add",
|
||||||
|
"create": "Create",
|
||||||
|
"update": "Update",
|
||||||
|
"view": "View",
|
||||||
|
"search": "Search",
|
||||||
|
"filter": "Filter",
|
||||||
|
"export": "Export",
|
||||||
|
"import": "Import",
|
||||||
|
"download": "Download",
|
||||||
|
"upload": "Upload",
|
||||||
|
"print": "Print",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"reset": "Reset",
|
||||||
|
"clear": "Clear",
|
||||||
|
"submit": "Submit",
|
||||||
|
"close": "Close",
|
||||||
|
"open": "Open",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"finish": "Finish",
|
||||||
|
"continue": "Continue",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"expand": "Expand",
|
||||||
|
"collapse": "Collapse"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"pending": "Pending",
|
||||||
|
"completed": "Completed",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"draft": "Draft",
|
||||||
|
"published": "Published",
|
||||||
|
"archived": "Archived",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"available": "Available",
|
||||||
|
"unavailable": "Unavailable",
|
||||||
|
"in_progress": "In Progress",
|
||||||
|
"failed": "Failed",
|
||||||
|
"success": "Success",
|
||||||
|
"warning": "Warning",
|
||||||
|
"error": "Error",
|
||||||
|
"info": "Information",
|
||||||
|
"undefined": "Not defined",
|
||||||
|
"no_rating": "No rating",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"no_realtime_connection": "No real-time connection"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday",
|
||||||
|
"tomorrow": "Tomorrow",
|
||||||
|
"this_week": "This Week",
|
||||||
|
"last_week": "Last Week",
|
||||||
|
"next_week": "Next Week",
|
||||||
|
"this_month": "This Month",
|
||||||
|
"last_month": "Last Month",
|
||||||
|
"next_month": "Next Month",
|
||||||
|
"this_year": "This Year",
|
||||||
|
"last_year": "Last Year",
|
||||||
|
"next_year": "Next Year",
|
||||||
|
"morning": "Morning",
|
||||||
|
"afternoon": "Afternoon",
|
||||||
|
"evening": "Evening",
|
||||||
|
"night": "Night",
|
||||||
|
"now": "Now",
|
||||||
|
"recently": "Recently",
|
||||||
|
"soon": "Soon",
|
||||||
|
"later": "Later"
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"kg": "kg",
|
||||||
|
"g": "g",
|
||||||
|
"l": "l",
|
||||||
|
"ml": "ml",
|
||||||
|
"pieces": "pieces",
|
||||||
|
"units": "units",
|
||||||
|
"portions": "portions",
|
||||||
|
"minutes": "minutes",
|
||||||
|
"hours": "hours",
|
||||||
|
"days": "days",
|
||||||
|
"weeks": "weeks",
|
||||||
|
"months": "months",
|
||||||
|
"years": "years"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"bread": "Breads",
|
||||||
|
"pastry": "Pastries",
|
||||||
|
"cake": "Cakes",
|
||||||
|
"cookie": "Cookies",
|
||||||
|
"other": "Others",
|
||||||
|
"flour": "Flours",
|
||||||
|
"dairy": "Dairy",
|
||||||
|
"eggs": "Eggs",
|
||||||
|
"fats": "Fats",
|
||||||
|
"sugar": "Sugars",
|
||||||
|
"yeast": "Yeasts",
|
||||||
|
"spices": "Spices",
|
||||||
|
"salted": "Savory"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"low": "Low",
|
||||||
|
"normal": "Normal",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High",
|
||||||
|
"urgent": "Urgent",
|
||||||
|
"critical": "Critical",
|
||||||
|
"undefined": "Priority not defined"
|
||||||
|
},
|
||||||
|
"difficulty": {
|
||||||
|
"easy": "Easy",
|
||||||
|
"medium": "Medium",
|
||||||
|
"hard": "Hard",
|
||||||
|
"expert": "Expert"
|
||||||
|
},
|
||||||
|
"payment_methods": {
|
||||||
|
"cash": "Cash",
|
||||||
|
"card": "Card",
|
||||||
|
"transfer": "Transfer",
|
||||||
|
"other": "Other"
|
||||||
|
},
|
||||||
|
"delivery_methods": {
|
||||||
|
"pickup": "Pickup",
|
||||||
|
"delivery": "Home Delivery"
|
||||||
|
},
|
||||||
|
"weekdays": {
|
||||||
|
"monday": "Monday",
|
||||||
|
"tuesday": "Tuesday",
|
||||||
|
"wednesday": "Wednesday",
|
||||||
|
"thursday": "Thursday",
|
||||||
|
"friday": "Friday",
|
||||||
|
"saturday": "Saturday",
|
||||||
|
"sunday": "Sunday"
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"january": "January",
|
||||||
|
"february": "February",
|
||||||
|
"march": "March",
|
||||||
|
"april": "April",
|
||||||
|
"may": "May",
|
||||||
|
"june": "June",
|
||||||
|
"july": "July",
|
||||||
|
"august": "August",
|
||||||
|
"september": "September",
|
||||||
|
"october": "October",
|
||||||
|
"november": "November",
|
||||||
|
"december": "December"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"required": "Required",
|
||||||
|
"optional": "Optional",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"no_data": "No data available",
|
||||||
|
"no_results": "No results found",
|
||||||
|
"empty_state": "No items to display",
|
||||||
|
"select_option": "Select option",
|
||||||
|
"enter_text": "Enter text",
|
||||||
|
"choose_file": "Choose file",
|
||||||
|
"drag_drop": "Drag and drop here",
|
||||||
|
"or": "or",
|
||||||
|
"no_terms": "No terms defined",
|
||||||
|
"search_placeholder": "Search..."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"no_data": "No data to display",
|
||||||
|
"loading": "Loading data...",
|
||||||
|
"error": "Error loading data",
|
||||||
|
"rows_per_page": "Rows per page",
|
||||||
|
"showing": "Showing",
|
||||||
|
"of": "of",
|
||||||
|
"entries": "entries",
|
||||||
|
"page": "Page",
|
||||||
|
"first": "First",
|
||||||
|
"last": "Last",
|
||||||
|
"sort_asc": "Sort ascending",
|
||||||
|
"sort_desc": "Sort descending"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"confirm_delete": "Are you sure you want to delete this item?",
|
||||||
|
"confirm_action": "Are you sure you want to perform this action?",
|
||||||
|
"unsaved_changes": "You have unsaved changes. Are you sure you want to leave?",
|
||||||
|
"success_save": "Saved successfully",
|
||||||
|
"success_delete": "Deleted successfully",
|
||||||
|
"success_update": "Updated successfully",
|
||||||
|
"success_create": "Created successfully",
|
||||||
|
"operation_completed": "Operation completed successfully"
|
||||||
|
},
|
||||||
|
"accessibility": {
|
||||||
|
"close": "Close",
|
||||||
|
"menu": "Main navigation",
|
||||||
|
"open_menu": "Open menu",
|
||||||
|
"close_menu": "Close menu",
|
||||||
|
"toggle": "Toggle",
|
||||||
|
"expand": "Expand",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"loading": "Loading",
|
||||||
|
"image": "Image",
|
||||||
|
"button": "Button",
|
||||||
|
"link": "Link",
|
||||||
|
"tooltip": "Additional information",
|
||||||
|
"search": "Search in the application"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
frontend/src/locales/en/dashboard.json
Normal file
74
frontend/src/locales/en/dashboard.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"title": "Dashboard",
|
||||||
|
"subtitle": "Overview of your bakery operations",
|
||||||
|
"stats": {
|
||||||
|
"sales_today": "Sales Today",
|
||||||
|
"pending_orders": "Pending Orders",
|
||||||
|
"stock_alerts": "Stock Alerts",
|
||||||
|
"production_efficiency": "Production Efficiency",
|
||||||
|
"revenue_growth": "Revenue Growth",
|
||||||
|
"customer_satisfaction": "Customer Satisfaction",
|
||||||
|
"inventory_turnover": "Inventory Turnover",
|
||||||
|
"daily_profit": "Daily Profit",
|
||||||
|
"products_sold": "Products Sold"
|
||||||
|
},
|
||||||
|
"trends": {
|
||||||
|
"vs_yesterday": "% vs yesterday",
|
||||||
|
"vs_last_week": "% vs last week",
|
||||||
|
"vs_last_month": "% vs last month",
|
||||||
|
"growth": "growth",
|
||||||
|
"decrease": "decrease",
|
||||||
|
"stable": "stable"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"critical_stats": "Critical Statistics",
|
||||||
|
"real_time_alerts": "Real-time Alerts",
|
||||||
|
"procurement_today": "Procurement Today",
|
||||||
|
"production_today": "Production Today",
|
||||||
|
"recent_activity": "Recent Activity",
|
||||||
|
"quick_actions": "Quick Actions"
|
||||||
|
},
|
||||||
|
"quick_actions": {
|
||||||
|
"add_new_bakery": "Add New Bakery",
|
||||||
|
"create_order": "Create Order",
|
||||||
|
"start_production": "Start Production",
|
||||||
|
"check_inventory": "Check Inventory",
|
||||||
|
"view_reports": "View Reports",
|
||||||
|
"manage_staff": "Manage Staff"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"low_stock": "Low Stock",
|
||||||
|
"production_delay": "Production Delay",
|
||||||
|
"quality_issue": "Quality Issue",
|
||||||
|
"equipment_maintenance": "Equipment Maintenance",
|
||||||
|
"order_pending": "Order Pending",
|
||||||
|
"delivery_due": "Delivery Due"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"welcome": "Welcome back",
|
||||||
|
"good_morning": "Good morning",
|
||||||
|
"good_afternoon": "Good afternoon",
|
||||||
|
"good_evening": "Good evening",
|
||||||
|
"no_data": "No data available",
|
||||||
|
"loading": "Loading dashboard data...",
|
||||||
|
"error_loading": "Error loading data",
|
||||||
|
"last_updated": "Last updated",
|
||||||
|
"auto_refresh": "Auto refresh in",
|
||||||
|
"more_than_yesterday": "more than yesterday",
|
||||||
|
"require_attention": "Require attention",
|
||||||
|
"more_units": "more units",
|
||||||
|
"action_required": "Action required",
|
||||||
|
"manage_organizations": "Manage your organizations",
|
||||||
|
"setup_new_business": "Set up a new business from scratch",
|
||||||
|
"active_organizations": "Active Organizations"
|
||||||
|
},
|
||||||
|
"time_periods": {
|
||||||
|
"today": "Today",
|
||||||
|
"this_week": "This Week",
|
||||||
|
"this_month": "This Month",
|
||||||
|
"this_year": "This Year",
|
||||||
|
"last_7_days": "Last 7 days",
|
||||||
|
"last_30_days": "Last 30 days",
|
||||||
|
"last_90_days": "Last 90 days"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
frontend/src/locales/en/production.json
Normal file
80
frontend/src/locales/en/production.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"title": "Production",
|
||||||
|
"subtitle": "Manage your bakery production",
|
||||||
|
"production_status": {
|
||||||
|
"PENDING": "Pending",
|
||||||
|
"IN_PROGRESS": "In Progress",
|
||||||
|
"COMPLETED": "Completed",
|
||||||
|
"CANCELLED": "Cancelled",
|
||||||
|
"ON_HOLD": "On Hold",
|
||||||
|
"QUALITY_CHECK": "Quality Check",
|
||||||
|
"FAILED": "Failed"
|
||||||
|
},
|
||||||
|
"production_priority": {
|
||||||
|
"LOW": "Low",
|
||||||
|
"MEDIUM": "Medium",
|
||||||
|
"HIGH": "High",
|
||||||
|
"URGENT": "Urgent"
|
||||||
|
},
|
||||||
|
"batch_status": {
|
||||||
|
"PLANNED": "Planned",
|
||||||
|
"IN_PROGRESS": "In Progress",
|
||||||
|
"COMPLETED": "Completed",
|
||||||
|
"CANCELLED": "Cancelled",
|
||||||
|
"ON_HOLD": "On Hold"
|
||||||
|
},
|
||||||
|
"quality_check_status": {
|
||||||
|
"PENDING": "Pending",
|
||||||
|
"IN_PROGRESS": "In Progress",
|
||||||
|
"PASSED": "Passed",
|
||||||
|
"FAILED": "Failed",
|
||||||
|
"REQUIRES_ATTENTION": "Requires Attention"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"batch_number": "Batch Number",
|
||||||
|
"production_date": "Production Date",
|
||||||
|
"planned_quantity": "Planned Quantity",
|
||||||
|
"actual_quantity": "Actual Quantity",
|
||||||
|
"yield_percentage": "Yield Percentage",
|
||||||
|
"priority": "Priority",
|
||||||
|
"assigned_staff": "Assigned Staff",
|
||||||
|
"production_notes": "Production Notes",
|
||||||
|
"quality_score": "Quality Score",
|
||||||
|
"quality_notes": "Quality Notes",
|
||||||
|
"defect_rate": "Defect Rate",
|
||||||
|
"rework_required": "Rework Required",
|
||||||
|
"waste_quantity": "Waste Quantity",
|
||||||
|
"waste_reason": "Waste Reason",
|
||||||
|
"efficiency": "Efficiency",
|
||||||
|
"material_cost": "Material Cost",
|
||||||
|
"labor_cost": "Labor Cost",
|
||||||
|
"overhead_cost": "Overhead Cost",
|
||||||
|
"total_cost": "Total Cost",
|
||||||
|
"cost_per_unit": "Cost per Unit"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"start_production": "Start Production",
|
||||||
|
"complete_batch": "Complete Batch",
|
||||||
|
"pause_production": "Pause Production",
|
||||||
|
"cancel_batch": "Cancel Batch",
|
||||||
|
"quality_check": "Quality Check",
|
||||||
|
"create_batch": "Create Batch",
|
||||||
|
"view_details": "View Details",
|
||||||
|
"edit_batch": "Edit Batch",
|
||||||
|
"duplicate_batch": "Duplicate Batch"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"current_production": "Current Production",
|
||||||
|
"production_queue": "Production Queue",
|
||||||
|
"completed_today": "Completed Today",
|
||||||
|
"efficiency_rate": "Efficiency Rate",
|
||||||
|
"quality_score": "Quality Score",
|
||||||
|
"active_batches": "Active Batches",
|
||||||
|
"pending_quality_checks": "Pending Quality Checks"
|
||||||
|
},
|
||||||
|
"descriptions": {
|
||||||
|
"production_efficiency": "Percentage of efficiency in current production",
|
||||||
|
"quality_average": "Average quality score in recent batches",
|
||||||
|
"waste_reduction": "Waste reduction compared to previous month"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,9 @@
|
|||||||
"previous": "Anterior",
|
"previous": "Anterior",
|
||||||
"finish": "Finalizar",
|
"finish": "Finalizar",
|
||||||
"continue": "Continuar",
|
"continue": "Continuar",
|
||||||
"confirm": "Confirmar"
|
"confirm": "Confirmar",
|
||||||
|
"expand": "Expandir",
|
||||||
|
"collapse": "Contraer"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Activo",
|
"active": "Activo",
|
||||||
@@ -75,7 +77,11 @@
|
|||||||
"success": "Éxito",
|
"success": "Éxito",
|
||||||
"warning": "Advertencia",
|
"warning": "Advertencia",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"info": "Información"
|
"info": "Información",
|
||||||
|
"undefined": "No definido",
|
||||||
|
"no_rating": "Sin calificación",
|
||||||
|
"disconnected": "Desconectado",
|
||||||
|
"no_realtime_connection": "Sin conexión en tiempo real"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"today": "Hoy",
|
"today": "Hoy",
|
||||||
@@ -135,7 +141,8 @@
|
|||||||
"medium": "Media",
|
"medium": "Media",
|
||||||
"high": "Alta",
|
"high": "Alta",
|
||||||
"urgent": "Urgente",
|
"urgent": "Urgente",
|
||||||
"critical": "Crítica"
|
"critical": "Crítica",
|
||||||
|
"undefined": "Prioridad no definida"
|
||||||
},
|
},
|
||||||
"difficulty": {
|
"difficulty": {
|
||||||
"easy": "Fácil",
|
"easy": "Fácil",
|
||||||
@@ -187,7 +194,9 @@
|
|||||||
"enter_text": "Ingresa texto",
|
"enter_text": "Ingresa texto",
|
||||||
"choose_file": "Elegir archivo",
|
"choose_file": "Elegir archivo",
|
||||||
"drag_drop": "Arrastra y suelta aquí",
|
"drag_drop": "Arrastra y suelta aquí",
|
||||||
"or": "o"
|
"or": "o",
|
||||||
|
"no_terms": "Sin términos definidos",
|
||||||
|
"search_placeholder": "Buscar..."
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"no_data": "No hay datos para mostrar",
|
"no_data": "No hay datos para mostrar",
|
||||||
@@ -215,7 +224,7 @@
|
|||||||
},
|
},
|
||||||
"accessibility": {
|
"accessibility": {
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
"menu": "Menú",
|
"menu": "Navegación principal",
|
||||||
"open_menu": "Abrir menú",
|
"open_menu": "Abrir menú",
|
||||||
"close_menu": "Cerrar menú",
|
"close_menu": "Cerrar menú",
|
||||||
"toggle": "Alternar",
|
"toggle": "Alternar",
|
||||||
@@ -225,6 +234,7 @@
|
|||||||
"image": "Imagen",
|
"image": "Imagen",
|
||||||
"button": "Botón",
|
"button": "Botón",
|
||||||
"link": "Enlace",
|
"link": "Enlace",
|
||||||
"tooltip": "Información adicional"
|
"tooltip": "Información adicional",
|
||||||
|
"search": "Buscar en la aplicación"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
74
frontend/src/locales/es/dashboard.json
Normal file
74
frontend/src/locales/es/dashboard.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"title": "Panel de Control",
|
||||||
|
"subtitle": "Resumen general de tu panadería",
|
||||||
|
"stats": {
|
||||||
|
"sales_today": "Ventas Hoy",
|
||||||
|
"pending_orders": "Órdenes Pendientes",
|
||||||
|
"stock_alerts": "Alertas de Stock",
|
||||||
|
"production_efficiency": "Eficiencia de Producción",
|
||||||
|
"revenue_growth": "Crecimiento de Ingresos",
|
||||||
|
"customer_satisfaction": "Satisfacción del Cliente",
|
||||||
|
"inventory_turnover": "Rotación de Inventario",
|
||||||
|
"daily_profit": "Ganancia Diaria",
|
||||||
|
"products_sold": "Productos Vendidos"
|
||||||
|
},
|
||||||
|
"trends": {
|
||||||
|
"vs_yesterday": "% vs ayer",
|
||||||
|
"vs_last_week": "% vs semana pasada",
|
||||||
|
"vs_last_month": "% vs mes pasado",
|
||||||
|
"growth": "crecimiento",
|
||||||
|
"decrease": "disminución",
|
||||||
|
"stable": "estable"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"critical_stats": "Estadísticas Críticas",
|
||||||
|
"real_time_alerts": "Alertas en Tiempo Real",
|
||||||
|
"procurement_today": "Compras Hoy",
|
||||||
|
"production_today": "Producción Hoy",
|
||||||
|
"recent_activity": "Actividad Reciente",
|
||||||
|
"quick_actions": "Acciones Rápidas"
|
||||||
|
},
|
||||||
|
"quick_actions": {
|
||||||
|
"add_new_bakery": "Agregar Nueva Panadería",
|
||||||
|
"create_order": "Crear Pedido",
|
||||||
|
"start_production": "Iniciar Producción",
|
||||||
|
"check_inventory": "Revisar Inventario",
|
||||||
|
"view_reports": "Ver Reportes",
|
||||||
|
"manage_staff": "Gestionar Personal"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"low_stock": "Stock Bajo",
|
||||||
|
"production_delay": "Retraso en Producción",
|
||||||
|
"quality_issue": "Problema de Calidad",
|
||||||
|
"equipment_maintenance": "Mantenimiento de Equipo",
|
||||||
|
"order_pending": "Pedido Pendiente",
|
||||||
|
"delivery_due": "Entrega Vencida"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"welcome": "Bienvenido de vuelta",
|
||||||
|
"good_morning": "Buenos días",
|
||||||
|
"good_afternoon": "Buenas tardes",
|
||||||
|
"good_evening": "Buenas noches",
|
||||||
|
"no_data": "No hay datos disponibles",
|
||||||
|
"loading": "Cargando datos del panel...",
|
||||||
|
"error_loading": "Error al cargar los datos",
|
||||||
|
"last_updated": "Última actualización",
|
||||||
|
"auto_refresh": "Actualización automática en",
|
||||||
|
"more_than_yesterday": "más que ayer",
|
||||||
|
"require_attention": "Requieren atención",
|
||||||
|
"more_units": "unidades más",
|
||||||
|
"action_required": "Acción requerida",
|
||||||
|
"manage_organizations": "Gestiona tus organizaciones",
|
||||||
|
"setup_new_business": "Configurar un nuevo negocio desde cero",
|
||||||
|
"active_organizations": "Organizaciones Activas"
|
||||||
|
},
|
||||||
|
"time_periods": {
|
||||||
|
"today": "Hoy",
|
||||||
|
"this_week": "Esta Semana",
|
||||||
|
"this_month": "Este Mes",
|
||||||
|
"this_year": "Este Año",
|
||||||
|
"last_7_days": "Últimos 7 días",
|
||||||
|
"last_30_days": "Últimos 30 días",
|
||||||
|
"last_90_days": "Últimos 90 días"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
frontend/src/locales/es/production.json
Normal file
80
frontend/src/locales/es/production.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"title": "Producción",
|
||||||
|
"subtitle": "Gestiona la producción de tu panadería",
|
||||||
|
"production_status": {
|
||||||
|
"PENDING": "Pendiente",
|
||||||
|
"IN_PROGRESS": "En Proceso",
|
||||||
|
"COMPLETED": "Completado",
|
||||||
|
"CANCELLED": "Cancelado",
|
||||||
|
"ON_HOLD": "En Pausa",
|
||||||
|
"QUALITY_CHECK": "Control Calidad",
|
||||||
|
"FAILED": "Fallido"
|
||||||
|
},
|
||||||
|
"production_priority": {
|
||||||
|
"LOW": "Baja",
|
||||||
|
"MEDIUM": "Media",
|
||||||
|
"HIGH": "Alta",
|
||||||
|
"URGENT": "Urgente"
|
||||||
|
},
|
||||||
|
"batch_status": {
|
||||||
|
"PLANNED": "Planificado",
|
||||||
|
"IN_PROGRESS": "En Proceso",
|
||||||
|
"COMPLETED": "Completado",
|
||||||
|
"CANCELLED": "Cancelado",
|
||||||
|
"ON_HOLD": "En Pausa"
|
||||||
|
},
|
||||||
|
"quality_check_status": {
|
||||||
|
"PENDING": "Pendiente",
|
||||||
|
"IN_PROGRESS": "En Proceso",
|
||||||
|
"PASSED": "Aprobado",
|
||||||
|
"FAILED": "Reprobado",
|
||||||
|
"REQUIRES_ATTENTION": "Requiere Atención"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"batch_number": "Número de Lote",
|
||||||
|
"production_date": "Fecha de Producción",
|
||||||
|
"planned_quantity": "Cantidad Planificada",
|
||||||
|
"actual_quantity": "Cantidad Real",
|
||||||
|
"yield_percentage": "Porcentaje de Rendimiento",
|
||||||
|
"priority": "Prioridad",
|
||||||
|
"assigned_staff": "Personal Asignado",
|
||||||
|
"production_notes": "Notas de Producción",
|
||||||
|
"quality_score": "Puntuación de Calidad",
|
||||||
|
"quality_notes": "Notas de Calidad",
|
||||||
|
"defect_rate": "Tasa de Defectos",
|
||||||
|
"rework_required": "Requiere Retrabajo",
|
||||||
|
"waste_quantity": "Cantidad de Desperdicio",
|
||||||
|
"waste_reason": "Razón del Desperdicio",
|
||||||
|
"efficiency": "Eficiencia",
|
||||||
|
"material_cost": "Costo de Materiales",
|
||||||
|
"labor_cost": "Costo de Mano de Obra",
|
||||||
|
"overhead_cost": "Costo Indirecto",
|
||||||
|
"total_cost": "Costo Total",
|
||||||
|
"cost_per_unit": "Costo por Unidad"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"start_production": "Iniciar Producción",
|
||||||
|
"complete_batch": "Completar Lote",
|
||||||
|
"pause_production": "Pausar Producción",
|
||||||
|
"cancel_batch": "Cancelar Lote",
|
||||||
|
"quality_check": "Control de Calidad",
|
||||||
|
"create_batch": "Crear Lote",
|
||||||
|
"view_details": "Ver Detalles",
|
||||||
|
"edit_batch": "Editar Lote",
|
||||||
|
"duplicate_batch": "Duplicar Lote"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"current_production": "Producción Actual",
|
||||||
|
"production_queue": "Cola de Producción",
|
||||||
|
"completed_today": "Completado Hoy",
|
||||||
|
"efficiency_rate": "Tasa de Eficiencia",
|
||||||
|
"quality_score": "Puntuación de Calidad",
|
||||||
|
"active_batches": "Lotes Activos",
|
||||||
|
"pending_quality_checks": "Controles de Calidad Pendientes"
|
||||||
|
},
|
||||||
|
"descriptions": {
|
||||||
|
"production_efficiency": "Porcentaje de eficiencia en la producción actual",
|
||||||
|
"quality_average": "Puntuación promedio de calidad en los últimos lotes",
|
||||||
|
"waste_reduction": "Reducción de desperdicio comparado con el mes anterior"
|
||||||
|
}
|
||||||
|
}
|
||||||
240
frontend/src/locales/eu/common.json
Normal file
240
frontend/src/locales/eu/common.json
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
{
|
||||||
|
"navigation": {
|
||||||
|
"dashboard": "Aginte Panela",
|
||||||
|
"operations": "Eragiketak",
|
||||||
|
"inventory": "Inbentarioa",
|
||||||
|
"production": "Ekoizpena",
|
||||||
|
"recipes": "Errezetak",
|
||||||
|
"orders": "Eskaerak",
|
||||||
|
"procurement": "Erosketak",
|
||||||
|
"pos": "Salmenta Puntua",
|
||||||
|
"analytics": "Analisiak",
|
||||||
|
"forecasting": "Aurreikuspena",
|
||||||
|
"sales": "Salmentak",
|
||||||
|
"performance": "Errendimendua",
|
||||||
|
"insights": "AI Jakintza",
|
||||||
|
"data": "Datuak",
|
||||||
|
"weather": "Eguraldia",
|
||||||
|
"traffic": "Trafikoa",
|
||||||
|
"events": "Gertaerak",
|
||||||
|
"communications": "Komunikazioak",
|
||||||
|
"notifications": "Jakinarazpenak",
|
||||||
|
"alerts": "Alertak",
|
||||||
|
"preferences": "Lehentasunak",
|
||||||
|
"settings": "Ezarpenak",
|
||||||
|
"team": "Taldea",
|
||||||
|
"bakery": "Okindegi",
|
||||||
|
"training": "Trebakuntza",
|
||||||
|
"system": "Sistema",
|
||||||
|
"onboarding": "Hasierako Konfigurazioa"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Gorde",
|
||||||
|
"cancel": "Ezeztatu",
|
||||||
|
"edit": "Editatu",
|
||||||
|
"delete": "Ezabatu",
|
||||||
|
"add": "Gehitu",
|
||||||
|
"create": "Sortu",
|
||||||
|
"update": "Eguneratu",
|
||||||
|
"view": "Ikusi",
|
||||||
|
"search": "Bilatu",
|
||||||
|
"filter": "Iragazi",
|
||||||
|
"export": "Esportatu",
|
||||||
|
"import": "Inportatu",
|
||||||
|
"download": "Deskargatu",
|
||||||
|
"upload": "Kargatu",
|
||||||
|
"print": "Inprimatu",
|
||||||
|
"refresh": "Eguneratu",
|
||||||
|
"reset": "Berrezarri",
|
||||||
|
"clear": "Garbitu",
|
||||||
|
"submit": "Bidali",
|
||||||
|
"close": "Itxi",
|
||||||
|
"open": "Ireki",
|
||||||
|
"back": "Atzera",
|
||||||
|
"next": "Hurrengoa",
|
||||||
|
"previous": "Aurrekoa",
|
||||||
|
"finish": "Amaitu",
|
||||||
|
"continue": "Jarraitu",
|
||||||
|
"confirm": "Berretsi",
|
||||||
|
"expand": "Zabaldu",
|
||||||
|
"collapse": "Tolestu"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "Aktibo",
|
||||||
|
"inactive": "Ez aktibo",
|
||||||
|
"pending": "Zain",
|
||||||
|
"completed": "Amaituta",
|
||||||
|
"cancelled": "Bertan behera utzi",
|
||||||
|
"draft": "Zirriborroa",
|
||||||
|
"published": "Argitaratua",
|
||||||
|
"archived": "Artxibatua",
|
||||||
|
"enabled": "Gaituta",
|
||||||
|
"disabled": "Desgaituta",
|
||||||
|
"available": "Erabilgarri",
|
||||||
|
"unavailable": "Ez erabilgarri",
|
||||||
|
"in_progress": "Abian",
|
||||||
|
"failed": "Huts egin du",
|
||||||
|
"success": "Arrakasta",
|
||||||
|
"warning": "Abisua",
|
||||||
|
"error": "Errorea",
|
||||||
|
"info": "Informazioa",
|
||||||
|
"undefined": "Zehaztu gabe",
|
||||||
|
"no_rating": "Baloraziorik ez",
|
||||||
|
"disconnected": "Deskonektatuta",
|
||||||
|
"no_realtime_connection": "Denbora errealeko konexiorik ez"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"today": "Gaur",
|
||||||
|
"yesterday": "Atzo",
|
||||||
|
"tomorrow": "Bihar",
|
||||||
|
"this_week": "Aste honetan",
|
||||||
|
"last_week": "Azken astean",
|
||||||
|
"next_week": "Hurrengo astean",
|
||||||
|
"this_month": "Hilabete honetan",
|
||||||
|
"last_month": "Azken hilabetean",
|
||||||
|
"next_month": "Hurrengo hilabetean",
|
||||||
|
"this_year": "Urte honetan",
|
||||||
|
"last_year": "Azken urtean",
|
||||||
|
"next_year": "Hurrengo urtean",
|
||||||
|
"morning": "Goiza",
|
||||||
|
"afternoon": "Arratsaldea",
|
||||||
|
"evening": "Iluntzea",
|
||||||
|
"night": "Gaua",
|
||||||
|
"now": "Orain",
|
||||||
|
"recently": "Duela gutxi",
|
||||||
|
"soon": "Laster",
|
||||||
|
"later": "Geroago"
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"kg": "kg",
|
||||||
|
"g": "g",
|
||||||
|
"l": "l",
|
||||||
|
"ml": "ml",
|
||||||
|
"pieces": "zatiak",
|
||||||
|
"units": "unitateak",
|
||||||
|
"portions": "zatiak",
|
||||||
|
"minutes": "minutuak",
|
||||||
|
"hours": "orduak",
|
||||||
|
"days": "egunak",
|
||||||
|
"weeks": "asteak",
|
||||||
|
"months": "hilabeteak",
|
||||||
|
"years": "urteak"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"bread": "Ogiak",
|
||||||
|
"pastry": "Gozogintza",
|
||||||
|
"cake": "Tartoak",
|
||||||
|
"cookie": "Galletak",
|
||||||
|
"other": "Besteak",
|
||||||
|
"flour": "Irinak",
|
||||||
|
"dairy": "Esnekiak",
|
||||||
|
"eggs": "Arrautzak",
|
||||||
|
"fats": "Gantzak",
|
||||||
|
"sugar": "Azukreak",
|
||||||
|
"yeast": "Legamiak",
|
||||||
|
"spices": "Espezieak",
|
||||||
|
"salted": "Gazidunak"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"low": "Baxua",
|
||||||
|
"normal": "Normala",
|
||||||
|
"medium": "Ertaina",
|
||||||
|
"high": "Altua",
|
||||||
|
"urgent": "Larria",
|
||||||
|
"critical": "Kritikoa",
|
||||||
|
"undefined": "Lehentasuna zehaztu gabe"
|
||||||
|
},
|
||||||
|
"difficulty": {
|
||||||
|
"easy": "Erraza",
|
||||||
|
"medium": "Ertaina",
|
||||||
|
"hard": "Zaila",
|
||||||
|
"expert": "Adituarena"
|
||||||
|
},
|
||||||
|
"payment_methods": {
|
||||||
|
"cash": "Dirua",
|
||||||
|
"card": "Txartela",
|
||||||
|
"transfer": "Transferentzia",
|
||||||
|
"other": "Besteak"
|
||||||
|
},
|
||||||
|
"delivery_methods": {
|
||||||
|
"pickup": "Hartzera",
|
||||||
|
"delivery": "Etxera banatzea"
|
||||||
|
},
|
||||||
|
"weekdays": {
|
||||||
|
"monday": "Astelehena",
|
||||||
|
"tuesday": "Asteartea",
|
||||||
|
"wednesday": "Asteazkena",
|
||||||
|
"thursday": "Osteguna",
|
||||||
|
"friday": "Ostirala",
|
||||||
|
"saturday": "Larunbata",
|
||||||
|
"sunday": "Igandea"
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"january": "Urtarrila",
|
||||||
|
"february": "Otsaila",
|
||||||
|
"march": "Martxoa",
|
||||||
|
"april": "Apirila",
|
||||||
|
"may": "Maiatza",
|
||||||
|
"june": "Ekaina",
|
||||||
|
"july": "Uztaila",
|
||||||
|
"august": "Abuztua",
|
||||||
|
"september": "Iraila",
|
||||||
|
"october": "Urria",
|
||||||
|
"november": "Azaroa",
|
||||||
|
"december": "Abendua"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"required": "Beharrezkoa",
|
||||||
|
"optional": "Aukerakoa",
|
||||||
|
"loading": "Kargatzen...",
|
||||||
|
"no_data": "Ez dago daturik",
|
||||||
|
"no_results": "Ez da emaitzarik aurkitu",
|
||||||
|
"empty_state": "Ez dago elementurik erakusteko",
|
||||||
|
"select_option": "Aukera hautatu",
|
||||||
|
"enter_text": "Testua sartu",
|
||||||
|
"choose_file": "Fitxategia aukeratu",
|
||||||
|
"drag_drop": "Arrastatu eta jaregin hemen",
|
||||||
|
"or": "edo",
|
||||||
|
"no_terms": "Baldintzarik zehaztu gabe",
|
||||||
|
"search_placeholder": "Bilatu..."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"no_data": "Ez dago daturik erakusteko",
|
||||||
|
"loading": "Datuak kargatzen...",
|
||||||
|
"error": "Errorea datuak kargatzean",
|
||||||
|
"rows_per_page": "Errenkadak orrialdepo",
|
||||||
|
"showing": "Erakusten",
|
||||||
|
"of": "-tik",
|
||||||
|
"entries": "sarrerak",
|
||||||
|
"page": "Orrialdea",
|
||||||
|
"first": "Lehena",
|
||||||
|
"last": "Azkena",
|
||||||
|
"sort_asc": "Ordenatu gorantz",
|
||||||
|
"sort_desc": "Ordenatu beherantz"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"confirm_delete": "Ziur zaude elementu hau ezabatu nahi duzula?",
|
||||||
|
"confirm_action": "Ziur zaude ekintza hau egin nahi duzula?",
|
||||||
|
"unsaved_changes": "Gorde gabeko aldaketak dituzu. Ziur zaude irten nahi duzula?",
|
||||||
|
"success_save": "Ongi gorde da",
|
||||||
|
"success_delete": "Ongi ezabatu da",
|
||||||
|
"success_update": "Ongi eguneratu da",
|
||||||
|
"success_create": "Ongi sortu da",
|
||||||
|
"operation_completed": "Eragiketa ongi burutu da"
|
||||||
|
},
|
||||||
|
"accessibility": {
|
||||||
|
"close": "Itxi",
|
||||||
|
"menu": "Nabigazio nagusia",
|
||||||
|
"open_menu": "Menua ireki",
|
||||||
|
"close_menu": "Menua itxi",
|
||||||
|
"toggle": "Aldatu",
|
||||||
|
"expand": "Zabaldu",
|
||||||
|
"collapse": "Tolestu",
|
||||||
|
"loading": "Kargatzen",
|
||||||
|
"image": "Irudia",
|
||||||
|
"button": "Botoia",
|
||||||
|
"link": "Esteka",
|
||||||
|
"tooltip": "Informazio gehigarria",
|
||||||
|
"search": "Aplikazioan bilatu"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
frontend/src/locales/eu/dashboard.json
Normal file
74
frontend/src/locales/eu/dashboard.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"title": "Aginte Panela",
|
||||||
|
"subtitle": "Zure okindegiaren eragiketen ikuspegi orokorra",
|
||||||
|
"stats": {
|
||||||
|
"sales_today": "Gaurko Salmentak",
|
||||||
|
"pending_orders": "Eskaera Zain",
|
||||||
|
"stock_alerts": "Stock Alertak",
|
||||||
|
"production_efficiency": "Ekoizpen Eraginkortasuna",
|
||||||
|
"revenue_growth": "Diru-sarrera Hazkundea",
|
||||||
|
"customer_satisfaction": "Bezeroaren Gogobetetasuna",
|
||||||
|
"inventory_turnover": "Inbentario Biraketa",
|
||||||
|
"daily_profit": "Eguneko Irabazia",
|
||||||
|
"products_sold": "Saldutako Produktuak"
|
||||||
|
},
|
||||||
|
"trends": {
|
||||||
|
"vs_yesterday": "% atzokoarekin alderatuta",
|
||||||
|
"vs_last_week": "% azken astearekin alderatuta",
|
||||||
|
"vs_last_month": "% azken hilarekin alderatuta",
|
||||||
|
"growth": "hazkundea",
|
||||||
|
"decrease": "beherakada",
|
||||||
|
"stable": "egonkorra"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"critical_stats": "Estatistika Kritikoak",
|
||||||
|
"real_time_alerts": "Denbora Errealeko Alertak",
|
||||||
|
"procurement_today": "Gaurko Erosketak",
|
||||||
|
"production_today": "Gaurko Ekoizpena",
|
||||||
|
"recent_activity": "Azken Jarduera",
|
||||||
|
"quick_actions": "Ekintza Azkarrak"
|
||||||
|
},
|
||||||
|
"quick_actions": {
|
||||||
|
"add_new_bakery": "Okindegi Berria Gehitu",
|
||||||
|
"create_order": "Eskaera Sortu",
|
||||||
|
"start_production": "Ekoizpena Hasi",
|
||||||
|
"check_inventory": "Inbentarioa Begiratu",
|
||||||
|
"view_reports": "Txostenak Ikusi",
|
||||||
|
"manage_staff": "Langilea Kudeatu"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"low_stock": "Stock Baxua",
|
||||||
|
"production_delay": "Ekoizpen Atzerapena",
|
||||||
|
"quality_issue": "Kalitate Arazoa",
|
||||||
|
"equipment_maintenance": "Ekipo Mantentze",
|
||||||
|
"order_pending": "Eskaera Zain",
|
||||||
|
"delivery_due": "Entrega Atzeratua"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"welcome": "Ongi etorri berriro",
|
||||||
|
"good_morning": "Egun on",
|
||||||
|
"good_afternoon": "Arratsalde on",
|
||||||
|
"good_evening": "Iluntzeko on",
|
||||||
|
"no_data": "Ez dago daturik",
|
||||||
|
"loading": "Aginte panelaren datuak kargatzen...",
|
||||||
|
"error_loading": "Errorea datuak kargatzean",
|
||||||
|
"last_updated": "Azken eguneraketa",
|
||||||
|
"auto_refresh": "Eguneraketa automatikoa",
|
||||||
|
"more_than_yesterday": "atzo baino gehiago",
|
||||||
|
"require_attention": "Arreta behar dute",
|
||||||
|
"more_units": "unitate gehiago",
|
||||||
|
"action_required": "Ekintza beharrezkoa",
|
||||||
|
"manage_organizations": "Zure erakundeak kudeatu",
|
||||||
|
"setup_new_business": "Negozio berri bat hutsetik konfiguratu",
|
||||||
|
"active_organizations": "Erakunde Aktiboak"
|
||||||
|
},
|
||||||
|
"time_periods": {
|
||||||
|
"today": "Gaur",
|
||||||
|
"this_week": "Aste Hau",
|
||||||
|
"this_month": "Hilabete Hau",
|
||||||
|
"this_year": "Urte Hau",
|
||||||
|
"last_7_days": "Azken 7 egun",
|
||||||
|
"last_30_days": "Azken 30 egun",
|
||||||
|
"last_90_days": "Azken 90 egun"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
frontend/src/locales/eu/production.json
Normal file
80
frontend/src/locales/eu/production.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"title": "Ekoizpena",
|
||||||
|
"subtitle": "Zure okindegiaren ekoizpena kudeatu",
|
||||||
|
"production_status": {
|
||||||
|
"PENDING": "Zain",
|
||||||
|
"IN_PROGRESS": "Abian",
|
||||||
|
"COMPLETED": "Amaituta",
|
||||||
|
"CANCELLED": "Bertan behera utzi",
|
||||||
|
"ON_HOLD": "Pausatuta",
|
||||||
|
"QUALITY_CHECK": "Kalitate Kontrola",
|
||||||
|
"FAILED": "Huts egin"
|
||||||
|
},
|
||||||
|
"production_priority": {
|
||||||
|
"LOW": "Baxua",
|
||||||
|
"MEDIUM": "Ertaina",
|
||||||
|
"HIGH": "Altua",
|
||||||
|
"URGENT": "Larria"
|
||||||
|
},
|
||||||
|
"batch_status": {
|
||||||
|
"PLANNED": "Planifikatuta",
|
||||||
|
"IN_PROGRESS": "Abian",
|
||||||
|
"COMPLETED": "Amaituta",
|
||||||
|
"CANCELLED": "Bertan behera utzi",
|
||||||
|
"ON_HOLD": "Pausatuta"
|
||||||
|
},
|
||||||
|
"quality_check_status": {
|
||||||
|
"PENDING": "Zain",
|
||||||
|
"IN_PROGRESS": "Abian",
|
||||||
|
"PASSED": "Onartuta",
|
||||||
|
"FAILED": "Baztertuta",
|
||||||
|
"REQUIRES_ATTENTION": "Arreta Behar du"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"batch_number": "Lote Zenbakia",
|
||||||
|
"production_date": "Ekoizpen Data",
|
||||||
|
"planned_quantity": "Planifikatutako Kantitatea",
|
||||||
|
"actual_quantity": "Benetako Kantitatea",
|
||||||
|
"yield_percentage": "Errendimendu Ehunekoa",
|
||||||
|
"priority": "Lehentasuna",
|
||||||
|
"assigned_staff": "Esleitutako Langilea",
|
||||||
|
"production_notes": "Ekoizpen Oharrak",
|
||||||
|
"quality_score": "Kalitate Puntuazioa",
|
||||||
|
"quality_notes": "Kalitate Oharrak",
|
||||||
|
"defect_rate": "Akats Tasa",
|
||||||
|
"rework_required": "Berrlana Behar",
|
||||||
|
"waste_quantity": "Hondakin Kantitatea",
|
||||||
|
"waste_reason": "Hondakin Arrazoia",
|
||||||
|
"efficiency": "Eraginkortasuna",
|
||||||
|
"material_cost": "Material Kostua",
|
||||||
|
"labor_cost": "Lan Kostua",
|
||||||
|
"overhead_cost": "Kostu Orokorra",
|
||||||
|
"total_cost": "Kostu Osoa",
|
||||||
|
"cost_per_unit": "Unitateko Kostua"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"start_production": "Ekoizpena Hasi",
|
||||||
|
"complete_batch": "Lotea Amaitu",
|
||||||
|
"pause_production": "Ekoizpena Pausatu",
|
||||||
|
"cancel_batch": "Lotea Ezeztatu",
|
||||||
|
"quality_check": "Kalitate Kontrola",
|
||||||
|
"create_batch": "Lotea Sortu",
|
||||||
|
"view_details": "Xehetasunak Ikusi",
|
||||||
|
"edit_batch": "Lotea Editatu",
|
||||||
|
"duplicate_batch": "Lotea Bikoiztu"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"current_production": "Uneko Ekoizpena",
|
||||||
|
"production_queue": "Ekoizpen Ilara",
|
||||||
|
"completed_today": "Gaur Amaitutakoak",
|
||||||
|
"efficiency_rate": "Eraginkortasun Tasa",
|
||||||
|
"quality_score": "Kalitate Puntuazioa",
|
||||||
|
"active_batches": "Lote Aktiboak",
|
||||||
|
"pending_quality_checks": "Kalitate Kontrol Zain"
|
||||||
|
},
|
||||||
|
"descriptions": {
|
||||||
|
"production_efficiency": "Uneko ekoizpenaren eraginkortasun ehunekoa",
|
||||||
|
"quality_average": "Azken loteen batez besteko kalitate puntuazioa",
|
||||||
|
"waste_reduction": "Hondakin murrizketa aurreko hilarekin alderatuta"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,19 @@ import suppliersEs from './es/suppliers.json';
|
|||||||
import ordersEs from './es/orders.json';
|
import ordersEs from './es/orders.json';
|
||||||
import recipesEs from './es/recipes.json';
|
import recipesEs from './es/recipes.json';
|
||||||
import errorsEs from './es/errors.json';
|
import errorsEs from './es/errors.json';
|
||||||
|
import dashboardEs from './es/dashboard.json';
|
||||||
|
import productionEs from './es/production.json';
|
||||||
|
|
||||||
|
// English translations
|
||||||
|
import commonEn from './en/common.json';
|
||||||
|
import recipesEn from './en/recipes.json';
|
||||||
|
import dashboardEn from './en/dashboard.json';
|
||||||
|
import productionEn from './en/production.json';
|
||||||
|
|
||||||
|
// Basque translations
|
||||||
|
import commonEu from './eu/common.json';
|
||||||
|
import dashboardEu from './eu/dashboard.json';
|
||||||
|
import productionEu from './eu/production.json';
|
||||||
|
|
||||||
// Translation resources by language
|
// Translation resources by language
|
||||||
export const resources = {
|
export const resources = {
|
||||||
@@ -19,11 +32,24 @@ export const resources = {
|
|||||||
orders: ordersEs,
|
orders: ordersEs,
|
||||||
recipes: recipesEs,
|
recipes: recipesEs,
|
||||||
errors: errorsEs,
|
errors: errorsEs,
|
||||||
|
dashboard: dashboardEs,
|
||||||
|
production: productionEs,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
common: commonEn,
|
||||||
|
recipes: recipesEn,
|
||||||
|
dashboard: dashboardEn,
|
||||||
|
production: productionEn,
|
||||||
|
},
|
||||||
|
eu: {
|
||||||
|
common: commonEu,
|
||||||
|
dashboard: dashboardEu,
|
||||||
|
production: productionEu,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Supported languages
|
// Supported languages
|
||||||
export const supportedLanguages = ['es'] as const;
|
export const supportedLanguages = ['es', 'en', 'eu'] as const;
|
||||||
export type SupportedLanguage = typeof supportedLanguages[number];
|
export type SupportedLanguage = typeof supportedLanguages[number];
|
||||||
|
|
||||||
// Default language
|
// Default language
|
||||||
@@ -38,10 +64,24 @@ export const languageConfig = {
|
|||||||
flag: '🇪🇸',
|
flag: '🇪🇸',
|
||||||
rtl: false,
|
rtl: false,
|
||||||
},
|
},
|
||||||
|
en: {
|
||||||
|
name: 'English',
|
||||||
|
nativeName: 'English',
|
||||||
|
code: 'en',
|
||||||
|
flag: '🇺🇸',
|
||||||
|
rtl: false,
|
||||||
|
},
|
||||||
|
eu: {
|
||||||
|
name: 'Euskera',
|
||||||
|
nativeName: 'Euskera',
|
||||||
|
code: 'eu',
|
||||||
|
flag: '🏴',
|
||||||
|
rtl: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Namespaces available in translations
|
// Namespaces available in translations
|
||||||
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors'] as const;
|
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production'] as const;
|
||||||
export type Namespace = typeof namespaces[number];
|
export type Namespace = typeof namespaces[number];
|
||||||
|
|
||||||
// Helper function to get language display name
|
// Helper function to get language display name
|
||||||
|
|||||||
@@ -1,67 +1,82 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PageHeader } from '../../components/layout';
|
import { PageHeader } from '../../components/layout';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { Card, CardHeader, CardBody } from '../../components/ui/Card';
|
||||||
import StatsGrid from '../../components/ui/Stats/StatsGrid';
|
import StatsGrid from '../../components/ui/Stats/StatsGrid';
|
||||||
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
|
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
|
||||||
import ProcurementPlansToday from '../../components/domain/dashboard/ProcurementPlansToday';
|
import ProcurementPlansToday from '../../components/domain/dashboard/ProcurementPlansToday';
|
||||||
import ProductionPlansToday from '../../components/domain/dashboard/ProductionPlansToday';
|
import ProductionPlansToday from '../../components/domain/dashboard/ProductionPlansToday';
|
||||||
|
import { useTenant } from '../../stores/tenant.store';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Package,
|
Package,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown
|
TrendingDown,
|
||||||
|
Plus,
|
||||||
|
Building2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const DashboardPage: React.FC = () => {
|
const DashboardPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { availableTenants } = useTenant();
|
||||||
|
|
||||||
|
const handleAddNewBakery = () => {
|
||||||
|
navigate('/app/onboarding?new=true');
|
||||||
|
};
|
||||||
|
|
||||||
const criticalStats = [
|
const criticalStats = [
|
||||||
{
|
{
|
||||||
title: 'Ventas Hoy',
|
title: t('dashboard:stats.sales_today', 'Sales Today'),
|
||||||
value: '€1,247',
|
value: '€1,247',
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
variant: 'success' as const,
|
variant: 'success' as const,
|
||||||
trend: {
|
trend: {
|
||||||
value: 12,
|
value: 12,
|
||||||
direction: 'up' as const,
|
direction: 'up' as const,
|
||||||
label: '% vs ayer'
|
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||||
},
|
},
|
||||||
subtitle: '+€135 más que ayer'
|
subtitle: '+€135 ' + t('dashboard:messages.more_than_yesterday', 'more than yesterday')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Órdenes Pendientes',
|
title: t('dashboard:stats.pending_orders', 'Pending Orders'),
|
||||||
value: '23',
|
value: '23',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
variant: 'warning' as const,
|
variant: 'warning' as const,
|
||||||
trend: {
|
trend: {
|
||||||
value: 4,
|
value: 4,
|
||||||
direction: 'down' as const,
|
direction: 'down' as const,
|
||||||
label: '% vs ayer'
|
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||||
},
|
},
|
||||||
subtitle: 'Requieren atención'
|
subtitle: t('dashboard:messages.require_attention', 'Require attention')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Productos Vendidos',
|
title: t('dashboard:stats.products_sold', 'Products Sold'),
|
||||||
value: '156',
|
value: '156',
|
||||||
icon: Package,
|
icon: Package,
|
||||||
variant: 'info' as const,
|
variant: 'info' as const,
|
||||||
trend: {
|
trend: {
|
||||||
value: 8,
|
value: 8,
|
||||||
direction: 'up' as const,
|
direction: 'up' as const,
|
||||||
label: '% vs ayer'
|
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||||
},
|
},
|
||||||
subtitle: '+12 unidades más'
|
subtitle: '+12 ' + t('dashboard:messages.more_units', 'more units')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Stock Crítico',
|
title: t('dashboard:stats.stock_alerts', 'Critical Stock'),
|
||||||
value: '4',
|
value: '4',
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
variant: 'error' as const,
|
variant: 'error' as const,
|
||||||
trend: {
|
trend: {
|
||||||
value: 100,
|
value: 100,
|
||||||
direction: 'up' as const,
|
direction: 'up' as const,
|
||||||
label: '% vs ayer'
|
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||||
},
|
},
|
||||||
subtitle: 'Acción requerida'
|
subtitle: t('dashboard:messages.action_required', 'Action required')
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -88,20 +103,54 @@ const DashboardPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Panel de Control"
|
title={t('dashboard:title', 'Dashboard')}
|
||||||
description="Vista general de tu panadería"
|
description={t('dashboard:subtitle', 'Overview of your bakery operations')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Critical Metrics using StatsGrid */}
|
{/* Critical Metrics using StatsGrid */}
|
||||||
<StatsGrid
|
<StatsGrid
|
||||||
stats={criticalStats}
|
stats={criticalStats}
|
||||||
columns={4}
|
columns={4}
|
||||||
title="Métricas Críticas"
|
|
||||||
description="Los datos más importantes para la gestión diaria de tu panadería"
|
|
||||||
gap="lg"
|
gap="lg"
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Quick Actions - Add New Bakery */}
|
||||||
|
{availableTenants && availableTenants.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('dashboard:sections.quick_actions', 'Quick Actions')}</h3>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">{t('dashboard:messages.manage_organizations', 'Manage your organizations')}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleAddNewBakery}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="h-auto p-6 flex flex-col items-center gap-3 bg-gradient-to-br from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 border-[var(--color-primary)]/20 hover:border-[var(--color-primary)]/40 hover:bg-[var(--color-primary)]/20 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Plus className="w-6 h-6 text-[var(--color-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.add_new_bakery', 'Add New Bakery')}</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)] mt-1">{t('dashboard:messages.setup_new_business', 'Set up a new business from scratch')}</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center p-6 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
|
||||||
|
<Building2 className="w-8 h-8 text-[var(--text-tertiary)] mb-2" />
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm font-medium text-[var(--text-secondary)]">{t('dashboard:messages.active_organizations', 'Active Organizations')}</div>
|
||||||
|
<div className="text-2xl font-bold text-[var(--color-primary)]">{availableTenants.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Full width blocks - one after another */}
|
{/* Full width blocks - one after another */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 1. Real-time alerts block */}
|
{/* 1. Real-time alerts block */}
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ const ProcurementPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Critical Requirements Modal */}
|
{/* Critical Requirements Modal */}
|
||||||
{showCriticalRequirements && selectedPlanForRequirements && (
|
{showCriticalRequirements && selectedPlanForRequirements && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed top-[var(--header-height)] left-0 right-0 bottom-0 bg-black bg-opacity-50 flex items-center justify-center z-40">
|
||||||
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4">
|
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
|
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
import { Button } from '../../../../components/ui/Button';
|
||||||
|
import { Card, CardHeader, CardBody } from '../../../../components/ui/Card';
|
||||||
|
import { Badge } from '../../../../components/ui/Badge';
|
||||||
|
import { Tooltip } from '../../../../components/ui/Tooltip';
|
||||||
|
import { useTenant } from '../../../../stores/tenant.store';
|
||||||
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Building2,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Globe,
|
||||||
|
MoreHorizontal,
|
||||||
|
ArrowRight,
|
||||||
|
Crown,
|
||||||
|
Shield,
|
||||||
|
Eye
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const OrganizationsPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const user = useAuthUser();
|
||||||
|
const { currentTenant, availableTenants, switchTenant } = useTenant();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleAddNewOrganization = () => {
|
||||||
|
navigate('/app/onboarding?new=true');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchToTenant = async (tenantId: string) => {
|
||||||
|
if (tenantId === currentTenant?.id) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
await switchTenant(tenantId);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageTenant = (tenantId: string) => {
|
||||||
|
// Navigate to tenant settings
|
||||||
|
navigate(`/app/database/bakery-config`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageTeam = (tenantId: string) => {
|
||||||
|
// Navigate to team management
|
||||||
|
navigate(`/app/database/team`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleIcon = (ownerId: string) => {
|
||||||
|
if (user?.id === ownerId) {
|
||||||
|
return <Crown className="w-4 h-4 text-[var(--color-warning)]" />;
|
||||||
|
}
|
||||||
|
return <Shield className="w-4 h-4 text-[var(--color-primary)]" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleLabel = (ownerId: string) => {
|
||||||
|
if (user?.id === ownerId) {
|
||||||
|
return 'Propietario';
|
||||||
|
}
|
||||||
|
return 'Miembro';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Mis Organizaciones"
|
||||||
|
description="Gestiona tus panaderías y negocios"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
onClick={handleAddNewOrganization}
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nueva Organización
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<Building2 className="w-8 h-8 text-[var(--color-primary)] mx-auto mb-2" />
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{availableTenants?.length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">Organizaciones Totales</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<Crown className="w-8 h-8 text-[var(--color-warning)] mx-auto mb-2" />
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{availableTenants?.filter(t => t.owner_id === user?.id).length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">Propietario</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<Shield className="w-8 h-8 text-[var(--color-primary)] mx-auto mb-2" />
|
||||||
|
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||||
|
{availableTenants?.filter(t => t.owner_id !== user?.id).length || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">Miembro</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organizations List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Tus Organizaciones</h3>
|
||||||
|
|
||||||
|
{availableTenants && availableTenants.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{availableTenants.map((tenant) => (
|
||||||
|
<Card
|
||||||
|
key={tenant.id}
|
||||||
|
className={`transition-all duration-200 hover:shadow-lg ${
|
||||||
|
currentTenant?.id === tenant.id
|
||||||
|
? 'ring-2 ring-[var(--color-primary)]/20 bg-[var(--color-primary)]/5'
|
||||||
|
: 'hover:border-[var(--color-primary)]/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4">
|
||||||
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
|
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Building2 className="w-6 h-6 text-[var(--color-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="font-semibold text-[var(--text-primary)] truncate">
|
||||||
|
{tenant.name}
|
||||||
|
</h4>
|
||||||
|
{currentTenant?.id === tenant.id && (
|
||||||
|
<Badge variant="primary" size="sm">Activa</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
{getRoleIcon(tenant.owner_id)}
|
||||||
|
<span>{getRoleLabel(tenant.owner_id)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Tooltip content="Configurar organización">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleManageTenant(tenant.id)}
|
||||||
|
className="w-8 h-8 p-0"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{user?.id === tenant.owner_id && (
|
||||||
|
<Tooltip content="Gestionar equipo">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleManageTeam(tenant.id)}
|
||||||
|
className="w-8 h-8 p-0"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody className="pt-0">
|
||||||
|
{/* Organization details */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{tenant.business_type && (
|
||||||
|
<div className="text-sm text-[var(--text-secondary)]">
|
||||||
|
<Badge variant="outline" size="sm">{tenant.business_type}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tenant.address && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{tenant.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tenant.city && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{tenant.city}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tenant.phone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
<Phone className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{tenant.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>Creada el {formatDate(tenant.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{currentTenant?.id !== tenant.id ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleSwitchToTenant(tenant.id)}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
Cambiar a esta organización
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/app/dashboard')}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
Ver dashboard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center py-12">
|
||||||
|
<Building2 className="w-16 h-16 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||||
|
No tienes organizaciones
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-6">
|
||||||
|
Crea tu primera organización para comenzar a usar Bakery IA
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddNewOrganization}
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Crear Primera Organización
|
||||||
|
</Button>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrganizationsPage;
|
||||||
@@ -30,6 +30,7 @@ const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics
|
|||||||
const ProfilePage = React.lazy(() => import('../pages/app/settings/profile/ProfilePage'));
|
const ProfilePage = React.lazy(() => import('../pages/app/settings/profile/ProfilePage'));
|
||||||
const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-config/BakeryConfigPage'));
|
const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-config/BakeryConfigPage'));
|
||||||
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
|
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
|
||||||
|
const OrganizationsPage = React.lazy(() => import('../pages/app/settings/organizations/OrganizationsPage'));
|
||||||
|
|
||||||
// Database pages
|
// Database pages
|
||||||
const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage'));
|
const DatabasePage = React.lazy(() => import('../pages/app/database/DatabasePage'));
|
||||||
@@ -232,15 +233,25 @@ export const AppRouter: React.FC = () => {
|
|||||||
|
|
||||||
|
|
||||||
{/* Settings Routes */}
|
{/* Settings Routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/app/settings/profile"
|
path="/app/settings/profile"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<ProfilePage />
|
<ProfilePage />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/app/settings/organizations"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell>
|
||||||
|
<OrganizationsPage />
|
||||||
|
</AppShell>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Data Routes */}
|
{/* Data Routes */}
|
||||||
|
|||||||
@@ -428,6 +428,16 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
showInNavigation: true,
|
showInNavigation: true,
|
||||||
showInBreadcrumbs: true,
|
showInBreadcrumbs: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/app/settings/organizations',
|
||||||
|
name: 'Organizations',
|
||||||
|
component: 'OrganizationsPage',
|
||||||
|
title: 'Mis Organizaciones',
|
||||||
|
icon: 'database',
|
||||||
|
requiresAuth: true,
|
||||||
|
showInNavigation: true,
|
||||||
|
showInBreadcrumbs: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ export const useTenantStore = create<TenantState>()(
|
|||||||
setCurrentTenant: (tenant: TenantResponse) => {
|
setCurrentTenant: (tenant: TenantResponse) => {
|
||||||
set({ currentTenant: tenant, currentTenantAccess: null });
|
set({ currentTenant: tenant, currentTenantAccess: null });
|
||||||
// Update API client with new tenant ID
|
// Update API client with new tenant ID
|
||||||
tenantService.setCurrentTenant(tenant);
|
if (tenant) {
|
||||||
// Load tenant access info
|
tenantService.setCurrentTenant(tenant);
|
||||||
get().loadCurrentTenantAccess();
|
// Load tenant access info
|
||||||
|
get().loadCurrentTenantAccess();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
switchTenant: async (tenantId: string): Promise<boolean> => {
|
switchTenant: async (tenantId: string): Promise<boolean> => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
|
||||||
export type Theme = 'light' | 'dark' | 'auto';
|
export type Theme = 'light' | 'dark' | 'auto';
|
||||||
export type Language = 'es' | 'en' | 'fr' | 'pt' | 'it';
|
export type Language = 'es' | 'en' | 'eu';
|
||||||
export type ViewMode = 'list' | 'grid' | 'card';
|
export type ViewMode = 'list' | 'grid' | 'card';
|
||||||
export type SidebarState = 'expanded' | 'collapsed' | 'hidden';
|
export type SidebarState = 'expanded' | 'collapsed' | 'hidden';
|
||||||
|
|
||||||
@@ -152,7 +152,12 @@ export const useUIStore = create<UIState>()(
|
|||||||
|
|
||||||
setLanguage: (language: Language) => {
|
setLanguage: (language: Language) => {
|
||||||
set({ language });
|
set({ language });
|
||||||
// You might want to trigger i18n language change here
|
// Trigger i18n language change only if different
|
||||||
|
import('../i18n').then(({ default: i18n }) => {
|
||||||
|
if (i18n.language !== language) {
|
||||||
|
i18n.changeLanguage(language);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setSidebarState: (sidebarState: SidebarState) => {
|
setSidebarState: (sidebarState: SidebarState) => {
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ export function useSupplierEnums() {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getSupplierTypeLabel: (type: SupplierType): string => {
|
getSupplierTypeLabel: (type: SupplierType): string => {
|
||||||
if (!type) return 'Tipo no definido';
|
if (!type) return t('common:status.undefined', 'Type not defined');
|
||||||
return t(`types.${type}`);
|
return t(`types.${type}`, type);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Supplier Status
|
// Supplier Status
|
||||||
@@ -89,8 +89,8 @@ export function useSupplierEnums() {
|
|||||||
enumToSelectOptions(SupplierStatus, 'status', t),
|
enumToSelectOptions(SupplierStatus, 'status', t),
|
||||||
|
|
||||||
getSupplierStatusLabel: (status: SupplierStatus): string => {
|
getSupplierStatusLabel: (status: SupplierStatus): string => {
|
||||||
if (!status) return 'Estado no definido';
|
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||||
return t(`status.${status}`);
|
return t(`status.${status}`, status);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Payment Terms
|
// Payment Terms
|
||||||
@@ -101,8 +101,8 @@ export function useSupplierEnums() {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getPaymentTermsLabel: (terms: PaymentTerms): string => {
|
getPaymentTermsLabel: (terms: PaymentTerms): string => {
|
||||||
if (!terms) return 'Sin términos definidos';
|
if (!terms) return t('common:forms.no_terms', 'No terms defined');
|
||||||
return t(`payment_terms.${terms}`);
|
return t(`payment_terms.${terms}`, terms);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Purchase Order Status
|
// Purchase Order Status
|
||||||
@@ -110,8 +110,8 @@ export function useSupplierEnums() {
|
|||||||
enumToSelectOptions(PurchaseOrderStatus, 'purchase_order_status', t),
|
enumToSelectOptions(PurchaseOrderStatus, 'purchase_order_status', t),
|
||||||
|
|
||||||
getPurchaseOrderStatusLabel: (status: PurchaseOrderStatus): string => {
|
getPurchaseOrderStatusLabel: (status: PurchaseOrderStatus): string => {
|
||||||
if (!status) return 'Estado no definido';
|
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||||
return t(`purchase_order_status.${status}`);
|
return t(`purchase_order_status.${status}`, status);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delivery Status
|
// Delivery Status
|
||||||
@@ -119,8 +119,8 @@ export function useSupplierEnums() {
|
|||||||
enumToSelectOptions(DeliveryStatus, 'delivery_status', t),
|
enumToSelectOptions(DeliveryStatus, 'delivery_status', t),
|
||||||
|
|
||||||
getDeliveryStatusLabel: (status: DeliveryStatus): string => {
|
getDeliveryStatusLabel: (status: DeliveryStatus): string => {
|
||||||
if (!status) return 'Estado no definido';
|
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||||
return t(`delivery_status.${status}`);
|
return t(`delivery_status.${status}`, status);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Quality Rating
|
// Quality Rating
|
||||||
@@ -131,8 +131,8 @@ export function useSupplierEnums() {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getQualityRatingLabel: (rating: QualityRating): string => {
|
getQualityRatingLabel: (rating: QualityRating): string => {
|
||||||
if (rating === undefined || rating === null) return 'Sin calificación';
|
if (rating === undefined || rating === null) return t('common:status.no_rating', 'No rating');
|
||||||
return t(`quality_rating.${rating}`);
|
return t(`quality_rating.${rating}`, rating.toString());
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delivery Rating
|
// Delivery Rating
|
||||||
@@ -143,8 +143,8 @@ export function useSupplierEnums() {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getDeliveryRatingLabel: (rating: DeliveryRating): string => {
|
getDeliveryRatingLabel: (rating: DeliveryRating): string => {
|
||||||
if (rating === undefined || rating === null) return 'Sin calificación';
|
if (rating === undefined || rating === null) return t('common:status.no_rating', 'No rating');
|
||||||
return t(`delivery_rating.${rating}`);
|
return t(`delivery_rating.${rating}`, rating.toString());
|
||||||
},
|
},
|
||||||
|
|
||||||
// Invoice Status
|
// Invoice Status
|
||||||
@@ -152,8 +152,8 @@ export function useSupplierEnums() {
|
|||||||
enumToSelectOptions(InvoiceStatus, 'invoice_status', t),
|
enumToSelectOptions(InvoiceStatus, 'invoice_status', t),
|
||||||
|
|
||||||
getInvoiceStatusLabel: (status: InvoiceStatus): string => {
|
getInvoiceStatusLabel: (status: InvoiceStatus): string => {
|
||||||
if (!status) return 'Estado no definido';
|
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||||
return t(`invoice_status.${status}`);
|
return t(`invoice_status.${status}`, status);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Field Labels
|
// Field Labels
|
||||||
@@ -197,12 +197,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(CustomerType, 'customer_types', t),
|
enumToSelectOptions(CustomerType, 'customer_types', t),
|
||||||
|
|
||||||
getCustomerTypeLabel: (type: CustomerType): string => {
|
getCustomerTypeLabel: (type: CustomerType): string => {
|
||||||
if (!type) return 'Tipo no definido';
|
if (!type) return t('common:status.undefined', 'Type not defined');
|
||||||
const translated = t(`customer_types.${type}`);
|
return t(`customer_types.${type}`, type.charAt(0).toUpperCase() + type.slice(1));
|
||||||
if (translated === `customer_types.${type}`) {
|
|
||||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
||||||
}
|
|
||||||
return translated;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delivery Method
|
// Delivery Method
|
||||||
@@ -210,12 +206,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(DeliveryMethod, 'delivery_methods', t),
|
enumToSelectOptions(DeliveryMethod, 'delivery_methods', t),
|
||||||
|
|
||||||
getDeliveryMethodLabel: (method: DeliveryMethod): string => {
|
getDeliveryMethodLabel: (method: DeliveryMethod): string => {
|
||||||
if (!method) return 'Método no definido';
|
if (!method) return t('common:status.undefined', 'Method not defined');
|
||||||
const translated = t(`delivery_methods.${method}`);
|
return t(`delivery_methods.${method}`, method.charAt(0).toUpperCase() + method.slice(1));
|
||||||
if (translated === `delivery_methods.${method}`) {
|
|
||||||
return method.charAt(0).toUpperCase() + method.slice(1);
|
|
||||||
}
|
|
||||||
return translated;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Payment Terms
|
// Payment Terms
|
||||||
@@ -223,8 +215,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(OrderPaymentTerms, 'payment_terms', t),
|
enumToSelectOptions(OrderPaymentTerms, 'payment_terms', t),
|
||||||
|
|
||||||
getPaymentTermsLabel: (terms: OrderPaymentTerms): string => {
|
getPaymentTermsLabel: (terms: OrderPaymentTerms): string => {
|
||||||
if (!terms) return 'Términos no definidos';
|
if (!terms) return t('common:forms.no_terms', 'Terms not defined');
|
||||||
return t(`payment_terms.${terms}`);
|
return t(`payment_terms.${terms}`, terms);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Payment Method
|
// Payment Method
|
||||||
@@ -232,8 +224,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(PaymentMethod, 'payment_methods', t),
|
enumToSelectOptions(PaymentMethod, 'payment_methods', t),
|
||||||
|
|
||||||
getPaymentMethodLabel: (method: PaymentMethod): string => {
|
getPaymentMethodLabel: (method: PaymentMethod): string => {
|
||||||
if (!method) return 'Método no definido';
|
if (!method) return t('common:status.undefined', 'Method not defined');
|
||||||
return t(`payment_methods.${method}`);
|
return t(`payment_methods.${method}`, method);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Payment Status
|
// Payment Status
|
||||||
@@ -241,8 +233,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(PaymentStatus, 'payment_status', t),
|
enumToSelectOptions(PaymentStatus, 'payment_status', t),
|
||||||
|
|
||||||
getPaymentStatusLabel: (status: PaymentStatus): string => {
|
getPaymentStatusLabel: (status: PaymentStatus): string => {
|
||||||
if (!status) return 'Estado no definido';
|
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||||
return t(`payment_status.${status}`);
|
return t(`payment_status.${status}`, status);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Customer Segment
|
// Customer Segment
|
||||||
@@ -250,8 +242,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(CustomerSegment, 'customer_segments', t),
|
enumToSelectOptions(CustomerSegment, 'customer_segments', t),
|
||||||
|
|
||||||
getCustomerSegmentLabel: (segment: CustomerSegment): string => {
|
getCustomerSegmentLabel: (segment: CustomerSegment): string => {
|
||||||
if (!segment) return 'Segmento no definido';
|
if (!segment) return t('common:status.undefined', 'Segment not defined');
|
||||||
return t(`customer_segments.${segment}`);
|
return t(`customer_segments.${segment}`, segment);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Priority Level
|
// Priority Level
|
||||||
@@ -259,8 +251,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(PriorityLevel, 'priority_levels', t),
|
enumToSelectOptions(PriorityLevel, 'priority_levels', t),
|
||||||
|
|
||||||
getPriorityLevelLabel: (level: PriorityLevel): string => {
|
getPriorityLevelLabel: (level: PriorityLevel): string => {
|
||||||
if (!level) return 'Prioridad no definida';
|
if (!level) return t('common:priority.undefined', 'Priority not defined');
|
||||||
return t(`priority_levels.${level}`);
|
return t(`priority_levels.${level}`, level);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Order Type
|
// Order Type
|
||||||
@@ -268,13 +260,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(OrderType, 'order_types', t),
|
enumToSelectOptions(OrderType, 'order_types', t),
|
||||||
|
|
||||||
getOrderTypeLabel: (type: OrderType): string => {
|
getOrderTypeLabel: (type: OrderType): string => {
|
||||||
if (!type) return 'Tipo no definido';
|
if (!type) return t('common:status.undefined', 'Type not defined');
|
||||||
const translated = t(`order_types.${type}`);
|
return t(`order_types.${type}`, type.charAt(0).toUpperCase() + type.slice(1));
|
||||||
// If translation failed, return a fallback
|
|
||||||
if (translated === `order_types.${type}`) {
|
|
||||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
||||||
}
|
|
||||||
return translated;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Order Status
|
// Order Status
|
||||||
@@ -282,8 +269,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(OrderStatus, 'order_status', t),
|
enumToSelectOptions(OrderStatus, 'order_status', t),
|
||||||
|
|
||||||
getOrderStatusLabel: (status: OrderStatus): string => {
|
getOrderStatusLabel: (status: OrderStatus): string => {
|
||||||
if (!status) return 'Estado no definido';
|
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||||
return t(`order_status.${status}`);
|
return t(`order_status.${status}`, status);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Order Source
|
// Order Source
|
||||||
@@ -291,8 +278,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(OrderSource, 'order_sources', t),
|
enumToSelectOptions(OrderSource, 'order_sources', t),
|
||||||
|
|
||||||
getOrderSourceLabel: (source: OrderSource): string => {
|
getOrderSourceLabel: (source: OrderSource): string => {
|
||||||
if (!source) return 'Origen no definido';
|
if (!source) return t('common:status.undefined', 'Source not defined');
|
||||||
return t(`order_sources.${source}`);
|
return t(`order_sources.${source}`, source);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sales Channel
|
// Sales Channel
|
||||||
@@ -300,8 +287,8 @@ export function useOrderEnums() {
|
|||||||
enumToSelectOptions(SalesChannel, 'sales_channels', t),
|
enumToSelectOptions(SalesChannel, 'sales_channels', t),
|
||||||
|
|
||||||
getSalesChannelLabel: (channel: SalesChannel): string => {
|
getSalesChannelLabel: (channel: SalesChannel): string => {
|
||||||
if (!channel) return 'Canal no definido';
|
if (!channel) return t('common:status.undefined', 'Channel not defined');
|
||||||
return t(`sales_channels.${channel}`);
|
return t(`sales_channels.${channel}`, channel);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Field Labels
|
// Field Labels
|
||||||
@@ -325,22 +312,8 @@ export function useProductionEnums() {
|
|||||||
enumToSelectOptions(ProductionStatusEnum, 'production_status', t),
|
enumToSelectOptions(ProductionStatusEnum, 'production_status', t),
|
||||||
|
|
||||||
getProductionStatusLabel: (status: ProductionStatusEnum): string => {
|
getProductionStatusLabel: (status: ProductionStatusEnum): string => {
|
||||||
if (!status) return 'Estado no definido';
|
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||||
const translated = t(`production_status.${status}`);
|
return t(`production_status.${status}`, status);
|
||||||
// If translation failed, return a fallback
|
|
||||||
if (translated === `production_status.${status}`) {
|
|
||||||
const fallbacks = {
|
|
||||||
[ProductionStatusEnum.PENDING]: 'Pendiente',
|
|
||||||
[ProductionStatusEnum.IN_PROGRESS]: 'En Proceso',
|
|
||||||
[ProductionStatusEnum.COMPLETED]: 'Completado',
|
|
||||||
[ProductionStatusEnum.CANCELLED]: 'Cancelado',
|
|
||||||
[ProductionStatusEnum.ON_HOLD]: 'En Pausa',
|
|
||||||
[ProductionStatusEnum.QUALITY_CHECK]: 'Control Calidad',
|
|
||||||
[ProductionStatusEnum.FAILED]: 'Fallido'
|
|
||||||
};
|
|
||||||
return fallbacks[status] || status;
|
|
||||||
}
|
|
||||||
return translated;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Production Priority
|
// Production Priority
|
||||||
@@ -348,19 +321,8 @@ export function useProductionEnums() {
|
|||||||
enumToSelectOptions(ProductionPriorityEnum, 'production_priority', t),
|
enumToSelectOptions(ProductionPriorityEnum, 'production_priority', t),
|
||||||
|
|
||||||
getProductionPriorityLabel: (priority: ProductionPriorityEnum): string => {
|
getProductionPriorityLabel: (priority: ProductionPriorityEnum): string => {
|
||||||
if (!priority) return 'Prioridad no definida';
|
if (!priority) return t('common:priority.undefined', 'Priority not defined');
|
||||||
const translated = t(`production_priority.${priority}`);
|
return t(`production_priority.${priority}`, priority);
|
||||||
// If translation failed, return a fallback
|
|
||||||
if (translated === `production_priority.${priority}`) {
|
|
||||||
const fallbacks = {
|
|
||||||
[ProductionPriorityEnum.LOW]: 'Baja',
|
|
||||||
[ProductionPriorityEnum.MEDIUM]: 'Media',
|
|
||||||
[ProductionPriorityEnum.HIGH]: 'Alta',
|
|
||||||
[ProductionPriorityEnum.URGENT]: 'Urgente'
|
|
||||||
};
|
|
||||||
return fallbacks[priority] || priority;
|
|
||||||
}
|
|
||||||
return translated;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Production Batch Status
|
// Production Batch Status
|
||||||
@@ -368,20 +330,8 @@ export function useProductionEnums() {
|
|||||||
enumToSelectOptions(ProductionBatchStatus, 'batch_status', t),
|
enumToSelectOptions(ProductionBatchStatus, 'batch_status', t),
|
||||||
|
|
||||||
getProductionBatchStatusLabel: (status: ProductionBatchStatus): string => {
|
getProductionBatchStatusLabel: (status: ProductionBatchStatus): string => {
|
||||||
if (!status) return 'Estado no definido';
|
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||||
const translated = t(`batch_status.${status}`);
|
return t(`batch_status.${status}`, status);
|
||||||
// If translation failed, return a fallback
|
|
||||||
if (translated === `batch_status.${status}`) {
|
|
||||||
const fallbacks = {
|
|
||||||
[ProductionBatchStatus.PLANNED]: 'Planificado',
|
|
||||||
[ProductionBatchStatus.IN_PROGRESS]: 'En Proceso',
|
|
||||||
[ProductionBatchStatus.COMPLETED]: 'Completado',
|
|
||||||
[ProductionBatchStatus.CANCELLED]: 'Cancelado',
|
|
||||||
[ProductionBatchStatus.ON_HOLD]: 'En Pausa'
|
|
||||||
};
|
|
||||||
return fallbacks[status] || status;
|
|
||||||
}
|
|
||||||
return translated;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Quality Check Status
|
// Quality Check Status
|
||||||
@@ -389,20 +339,8 @@ export function useProductionEnums() {
|
|||||||
enumToSelectOptions(QualityCheckStatus, 'quality_check_status', t),
|
enumToSelectOptions(QualityCheckStatus, 'quality_check_status', t),
|
||||||
|
|
||||||
getQualityCheckStatusLabel: (status: QualityCheckStatus): string => {
|
getQualityCheckStatusLabel: (status: QualityCheckStatus): string => {
|
||||||
if (!status) return 'Estado no definido';
|
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||||
const translated = t(`quality_check_status.${status}`);
|
return t(`quality_check_status.${status}`, status);
|
||||||
// If translation failed, return a fallback
|
|
||||||
if (translated === `quality_check_status.${status}`) {
|
|
||||||
const fallbacks = {
|
|
||||||
[QualityCheckStatus.PENDING]: 'Pendiente',
|
|
||||||
[QualityCheckStatus.IN_PROGRESS]: 'En Proceso',
|
|
||||||
[QualityCheckStatus.PASSED]: 'Aprobado',
|
|
||||||
[QualityCheckStatus.FAILED]: 'Reprobado',
|
|
||||||
[QualityCheckStatus.REQUIRES_ATTENTION]: 'Requiere Atención'
|
|
||||||
};
|
|
||||||
return fallbacks[status] || status;
|
|
||||||
}
|
|
||||||
return translated;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Field Labels
|
// Field Labels
|
||||||
|
|||||||
Reference in New Issue
Block a user