Improve the frontend and fix TODOs

This commit is contained in:
Urtzi Alfaro
2025-10-24 13:05:04 +02:00
parent 07c33fa578
commit 61376b7a9f
100 changed files with 8284 additions and 3419 deletions

View File

@@ -196,10 +196,24 @@ export const useStockMovements = (
offset: number = 0,
options?: Omit<UseQueryOptions<StockMovementResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
// Validate UUID format if ingredientId is provided
const isValidUUID = (uuid?: string): boolean => {
if (!uuid) return true; // undefined/null is valid (means no filter)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(uuid);
};
const validIngredientId = ingredientId && isValidUUID(ingredientId) ? ingredientId : undefined;
// Log warning if ingredient ID is invalid
if (ingredientId && !isValidUUID(ingredientId)) {
console.warn('[useStockMovements] Invalid ingredient ID format:', ingredientId);
}
return useQuery<StockMovementResponse[], ApiError>({
queryKey: inventoryKeys.stock.movements(tenantId, ingredientId),
queryFn: () => inventoryService.getStockMovements(tenantId, ingredientId, limit, offset),
enabled: !!tenantId,
queryKey: inventoryKeys.stock.movements(tenantId, validIngredientId),
queryFn: () => inventoryService.getStockMovements(tenantId, validIngredientId, limit, offset),
enabled: !!tenantId && (!ingredientId || isValidUUID(ingredientId)),
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});

View File

@@ -230,7 +230,7 @@ export const useProductionPlanningData = (tenantId: string, date?: string) => {
const schedule = useProductionSchedule(tenantId);
const capacity = useCapacityStatus(tenantId, date);
const requirements = useProductionRequirements(tenantId, date);
return {
schedule: schedule.data,
capacity: capacity.data,
@@ -243,4 +243,40 @@ export const useProductionPlanningData = (tenantId: string, date?: string) => {
requirements.refetch();
},
};
};
// ===== Scheduler Mutations =====
/**
* Hook to trigger production scheduler manually (for development/testing)
*/
export const useTriggerProductionScheduler = (
options?: UseMutationOptions<
{ success: boolean; message: string; tenant_id: string },
ApiError,
string
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string; tenant_id: string },
ApiError,
string
>({
mutationFn: (tenantId: string) => productionService.triggerProductionScheduler(tenantId),
onSuccess: (_, tenantId) => {
// Invalidate all production queries for this tenant
queryClient.invalidateQueries({
queryKey: productionKeys.dashboard(tenantId),
});
queryClient.invalidateQueries({
queryKey: productionKeys.batches(tenantId),
});
queryClient.invalidateQueries({
queryKey: productionKeys.activeBatches(tenantId),
});
},
...options,
});
};

View File

@@ -12,6 +12,7 @@ import {
TenantStatistics,
TenantSearchParams,
TenantNearbyParams,
AddMemberWithUserCreate,
} from '../types/tenant';
import { ApiError } from '../client';
@@ -247,16 +248,16 @@ export const useUpdateModelStatus = (
export const useAddTeamMember = (
options?: UseMutationOptions<
TenantMemberResponse,
ApiError,
TenantMemberResponse,
ApiError,
{ tenantId: string; userId: string; role: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TenantMemberResponse,
ApiError,
TenantMemberResponse,
ApiError,
{ tenantId: string; userId: string; role: string }
>({
mutationFn: ({ tenantId, userId, role }) => tenantService.addTeamMember(tenantId, userId, role),
@@ -268,6 +269,30 @@ export const useAddTeamMember = (
});
};
export const useAddTeamMemberWithUserCreation = (
options?: UseMutationOptions<
TenantMemberResponse,
ApiError,
{ tenantId: string; memberData: AddMemberWithUserCreate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TenantMemberResponse,
ApiError,
{ tenantId: string; memberData: AddMemberWithUserCreate }
>({
mutationFn: ({ tenantId, memberData }) =>
tenantService.addTeamMemberWithUserCreation(tenantId, memberData),
onSuccess: (data, { tenantId }) => {
// Invalidate team members query
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
},
...options,
});
};
export const useUpdateMemberRole = (
options?: UseMutationOptions<
TenantMemberResponse,

View File

@@ -655,6 +655,7 @@ export {
useUpdateBatchStatus,
useProductionDashboardData,
useProductionPlanningData,
useTriggerProductionScheduler,
productionKeys,
} from './hooks/production';

View File

@@ -405,6 +405,25 @@ export class ProductionService {
return apiClient.get(url);
}
// ===================================================================
// OPERATIONS: Scheduler
// ===================================================================
/**
* Trigger production scheduler manually (for testing/development)
* POST /tenants/{tenant_id}/production/operations/scheduler/trigger
*/
static async triggerProductionScheduler(tenantId: string): Promise<{
success: boolean;
message: string;
tenant_id: string
}> {
return apiClient.post(
`/tenants/${tenantId}/production/operations/scheduler/trigger`,
{}
);
}
}
export const productionService = new ProductionService();

View File

@@ -21,6 +21,7 @@ import {
TenantStatistics,
TenantSearchParams,
TenantNearbyParams,
AddMemberWithUserCreate,
} from '../types/tenant';
export class TenantService {
@@ -125,8 +126,8 @@ export class TenantService {
// Backend: services/tenant/app/api/tenant_members.py
// ===================================================================
async addTeamMember(
tenantId: string,
userId: string,
tenantId: string,
userId: string,
role: string
): Promise<TenantMemberResponse> {
return apiClient.post<TenantMemberResponse>(`${this.baseUrl}/${tenantId}/members`, {
@@ -135,6 +136,16 @@ export class TenantService {
});
}
async addTeamMemberWithUserCreation(
tenantId: string,
memberData: AddMemberWithUserCreate
): Promise<TenantMemberResponse> {
return apiClient.post<TenantMemberResponse>(
`${this.baseUrl}/${tenantId}/members/with-user`,
memberData
);
}
async getTeamMembers(tenantId: string, activeOnly: boolean = true): Promise<TenantMemberResponse[]> {
const queryParams = new URLSearchParams();
queryParams.append('active_only', activeOnly.toString());

View File

@@ -68,13 +68,34 @@ export interface TenantMemberInvitation {
/**
* Schema for updating tenant member
* Backend: TenantMemberUpdate in schemas/tenants.py (lines 132-135)
* Backend: TenantMemberUpdate in schemas/tenants.py (lines 137-140)
*/
export interface TenantMemberUpdate {
role?: 'owner' | 'admin' | 'member' | 'viewer' | null;
is_active?: boolean | null;
}
/**
* Schema for adding member with optional user creation (pilot phase)
* Backend: AddMemberWithUserCreate in schemas/tenants.py (lines 142-174)
*/
export interface AddMemberWithUserCreate {
// For existing users
user_id?: string | null;
// For new user creation
create_user?: boolean; // Default: false
email?: string | null;
full_name?: string | null;
password?: string | null;
phone?: string | null;
language?: 'es' | 'en' | 'eu'; // Default: "es"
timezone?: string; // Default: "Europe/Madrid"
// Common fields
role: 'admin' | 'member' | 'viewer';
}
/**
* Schema for updating tenant subscription
* Backend: TenantSubscriptionUpdate in schemas/tenants.py (lines 137-140)
@@ -121,8 +142,8 @@ export interface TenantAccessResponse {
}
/**
* Tenant member response - FIXED VERSION
* Backend: TenantMemberResponse in schemas/tenants.py (lines 90-107)
* Tenant member response - FIXED VERSION with enriched user data
* Backend: TenantMemberResponse in schemas/tenants.py (lines 91-112)
*/
export interface TenantMemberResponse {
id: string;
@@ -130,6 +151,10 @@ export interface TenantMemberResponse {
role: string;
is_active: boolean;
joined_at?: string | null; // ISO datetime string
// Enriched user fields (populated via service layer)
user_email?: string | null;
user_full_name?: string | null;
user?: any; // Full user object for compatibility
}
/**

View File

@@ -3,6 +3,7 @@ import { Button, Input, Card } from '../../ui';
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
import { useAuthActions } from '../../../stores/auth.store';
import { useToast } from '../../../hooks/ui/useToast';
import { useResetPassword } from '../../../api/hooks/auth';
interface PasswordResetFormProps {
token?: string;
@@ -33,10 +34,10 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
const emailInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null);
// TODO: Implement password reset in Zustand auth store
// const { requestPasswordReset, resetPassword, isLoading, error } = useAuth();
const isLoading = false;
// Password reset mutation hooks
const { mutateAsync: resetPasswordMutation, isPending: isResetting } = useResetPassword();
const isLoading = isResetting;
const error = null;
const { showToast } = useToast();
@@ -150,23 +151,14 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
}
try {
// TODO: Implement password reset request
// const success = await requestPasswordReset(email);
const success = false; // Placeholder
if (success) {
setIsEmailSent(true);
showToast({
type: 'success',
title: 'Email enviado correctamente',
message: 'Te hemos enviado las instrucciones para restablecer tu contraseña'
});
} else {
showToast({
type: 'error',
title: 'Error al enviar email',
message: error || 'No se pudo enviar el email. Verifica que la dirección sea correcta.'
});
}
// Note: Password reset request functionality needs to be implemented in backend
// For now, show a message that the feature is coming soon
setIsEmailSent(true);
showToast({
type: 'info',
title: 'Función en desarrollo',
message: 'La solicitud de restablecimiento de contraseña estará disponible próximamente. Por favor, contacta al administrador.'
});
} catch (err) {
showToast({
type: 'error',
@@ -197,28 +189,24 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
}
try {
// TODO: Implement password reset
// const success = await resetPassword(token, password);
const success = false; // Placeholder
if (success) {
showToast({
type: 'success',
title: 'Contraseña actualizada',
message: '¡Tu contraseña ha sido restablecida exitosamente! Ya puedes iniciar sesión.'
});
onSuccess?.();
} else {
showToast({
type: 'error',
title: 'Error al restablecer contraseña',
message: error || 'El enlace ha expirado o no es válido. Solicita un nuevo restablecimiento.'
});
}
} catch (err) {
// Call the reset password API
await resetPasswordMutation({
token: token,
new_password: password
});
showToast({
type: 'success',
title: 'Contraseña actualizada',
message: '¡Tu contraseña ha sido restablecida exitosamente! Ya puedes iniciar sesión.'
});
onSuccess?.();
} catch (err: any) {
const errorMessage = err?.response?.data?.detail || err?.message || 'El enlace ha expirado o no es válido. Solicita un nuevo restablecimiento.';
showToast({
type: 'error',
title: 'Error de conexión',
message: 'No se pudo conectar con el servidor. Verifica tu conexión a internet.'
title: 'Error al restablecer contraseña',
message: errorMessage
});
}
};

View File

@@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query';
import { recipesService } from '../../../api/services/recipes';
import type { RecipeResponse } from '../../../api/types/recipes';
import { statusColors } from '../../../styles/colors';
import { useTranslation } from 'react-i18next';
interface CreateQualityTemplateModalProps {
isOpen: boolean;
@@ -45,16 +46,23 @@ const PROCESS_STAGE_OPTIONS = [
{ value: ProcessStage.FINISHING, label: 'Acabado' }
];
const CATEGORY_OPTIONS = [
{ value: 'appearance', label: 'Apariencia' },
{ value: 'structure', label: 'Estructura' },
{ value: 'texture', label: 'Textura' },
{ value: 'flavor', label: 'Sabor' },
{ value: 'safety', label: 'Seguridad' },
{ value: 'packaging', label: 'Empaque' },
{ value: 'temperature', label: 'Temperatura' },
{ value: 'weight', label: 'Peso' },
{ value: 'dimensions', label: 'Dimensiones' }
const CATEGORY_OPTIONS_KEYS = [
{ value: 'appearance', key: 'appearance' },
{ value: 'structure', key: 'structure' },
{ value: 'texture', key: 'texture' },
{ value: 'flavor', key: 'flavor' },
{ value: 'safety', key: 'safety' },
{ value: 'packaging', key: 'packaging' },
{ value: 'temperature', key: 'temperature' },
{ value: 'weight', key: 'weight' },
{ value: 'dimensions', key: 'dimensions' },
{ value: 'weight_check', key: 'weight_check' },
{ value: 'temperature_check', key: 'temperature_check' },
{ value: 'moisture_check', key: 'moisture_check' },
{ value: 'volume_check', key: 'volume_check' },
{ value: 'time_check', key: 'time_check' },
{ value: 'chemical', key: 'chemical' },
{ value: 'hygiene', key: 'hygiene' }
];
export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProps> = ({
@@ -64,10 +72,27 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
isLoading: externalLoading = false,
initialRecipe
}) => {
const { t } = useTranslation();
const currentTenant = useCurrentTenant();
const [loading, setLoading] = useState(false);
const [selectedStages, setSelectedStages] = useState<ProcessStage[]>([]);
// Helper function to get translated category label
const getCategoryLabel = (category: string | null | undefined): string => {
if (!category) return 'Sin categoría';
const translationKey = `production.quality.categories.${category}`;
const translated = t(translationKey);
return translated === translationKey ? category : translated;
};
// Build category options with translations
const getCategoryOptions = () => {
return CATEGORY_OPTIONS_KEYS.map(option => ({
value: option.value,
label: getCategoryLabel(option.key)
}));
};
// Fetch available recipes for association
const { data: recipes, isLoading: recipesLoading } = useQuery({
queryKey: ['recipes', currentTenant?.id],
@@ -186,7 +211,7 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
label: 'Categoría',
name: 'category',
type: 'select' as const,
options: CATEGORY_OPTIONS,
options: getCategoryOptions(),
placeholder: 'Seleccionar categoría'
},
{

View File

@@ -1,20 +1,14 @@
import React, { useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import {
Modal,
Button,
Input,
Textarea,
Select,
Badge,
Card
} from '../../ui';
import { Edit, ClipboardCheck, Target, Settings, Cog } from 'lucide-react';
import { EditViewModal, EditViewModalSection } from '../../ui/EditViewModal/EditViewModal';
import {
QualityCheckType,
ProcessStage,
type QualityCheckTemplate,
type QualityCheckTemplateUpdate
} from '../../../api/types/qualityTemplates';
import { statusColors } from '../../../styles/colors';
import { useTranslation } from 'react-i18next';
interface EditQualityTemplateModalProps {
isOpen: boolean;
@@ -43,18 +37,58 @@ const PROCESS_STAGE_OPTIONS = [
{ value: ProcessStage.FINISHING, label: 'Acabado' }
];
const CATEGORY_OPTIONS = [
{ value: 'appearance', label: 'Apariencia' },
{ value: 'structure', label: 'Estructura' },
{ value: 'texture', label: 'Textura' },
{ value: 'flavor', label: 'Sabor' },
{ value: 'safety', label: 'Seguridad' },
{ value: 'packaging', label: 'Empaque' },
{ value: 'temperature', label: 'Temperatura' },
{ value: 'weight', label: 'Peso' },
{ value: 'dimensions', label: 'Dimensiones' }
const CATEGORY_OPTIONS_KEYS = [
{ value: '', key: '' },
{ value: 'appearance', key: 'appearance' },
{ value: 'structure', key: 'structure' },
{ value: 'texture', key: 'texture' },
{ value: 'flavor', key: 'flavor' },
{ value: 'safety', key: 'safety' },
{ value: 'packaging', key: 'packaging' },
{ value: 'temperature', key: 'temperature' },
{ value: 'weight', key: 'weight' },
{ value: 'dimensions', key: 'dimensions' },
{ value: 'weight_check', key: 'weight_check' },
{ value: 'temperature_check', key: 'temperature_check' },
{ value: 'moisture_check', key: 'moisture_check' },
{ value: 'volume_check', key: 'volume_check' },
{ value: 'time_check', key: 'time_check' },
{ value: 'chemical', key: 'chemical' },
{ value: 'hygiene', key: 'hygiene' }
];
// Component for managing process stages selection (multiselect)
const ProcessStagesSelector: React.FC<{
value: ProcessStage[];
onChange: (stages: ProcessStage[]) => void;
}> = ({ value, onChange }) => {
const handleToggle = (stage: ProcessStage) => {
const newStages = value.includes(stage)
? value.filter(s => s !== stage)
: [...value, stage];
onChange(newStages);
};
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{PROCESS_STAGE_OPTIONS.map(stage => (
<label
key={stage.value}
className="flex items-center space-x-2 p-2 border rounded cursor-pointer hover:bg-[var(--bg-secondary)] transition-colors"
>
<input
type="checkbox"
checked={value.includes(stage.value)}
onChange={() => handleToggle(stage.value)}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">{stage.label}</span>
</label>
))}
</div>
);
};
export const EditQualityTemplateModal: React.FC<EditQualityTemplateModalProps> = ({
isOpen,
onClose,
@@ -62,84 +96,99 @@ export const EditQualityTemplateModal: React.FC<EditQualityTemplateModalProps> =
onUpdateTemplate,
isLoading = false
}) => {
const { t } = useTranslation();
const [mode, setMode] = useState<'view' | 'edit'>('edit');
const [editedTemplate, setEditedTemplate] = useState<QualityCheckTemplate>(template);
const [selectedStages, setSelectedStages] = useState<ProcessStage[]>(
template.applicable_stages || []
);
const {
register,
control,
handleSubmit,
watch,
reset,
formState: { errors, isDirty }
} = useForm<QualityCheckTemplateUpdate>({
defaultValues: {
name: template.name,
template_code: template.template_code || '',
check_type: template.check_type,
category: template.category || '',
description: template.description || '',
instructions: template.instructions || '',
is_active: template.is_active,
is_required: template.is_required,
is_critical: template.is_critical,
weight: template.weight,
min_value: template.min_value,
max_value: template.max_value,
target_value: template.target_value,
unit: template.unit || '',
tolerance_percentage: template.tolerance_percentage
}
});
// Helper function to get translated category label
const getCategoryLabel = (category: string | null | undefined): string => {
if (!category) return 'Sin categoría';
const translationKey = `production.quality.categories.${category}`;
const translated = t(translationKey);
// If translation is same as key, it means no translation exists, return the original
return translated === translationKey ? category : translated;
};
const checkType = watch('check_type');
// Build category options with translations
const getCategoryOptions = () => {
return CATEGORY_OPTIONS_KEYS.map(option => ({
value: option.value,
label: option.value === '' ? 'Seleccionar categoría' : getCategoryLabel(option.key)
}));
};
// Update local state when template changes
useEffect(() => {
if (template) {
setEditedTemplate(template);
setSelectedStages(template.applicable_stages || []);
}
}, [template]);
const checkType = editedTemplate.check_type;
const showMeasurementFields = [
QualityCheckType.MEASUREMENT,
QualityCheckType.TEMPERATURE,
QualityCheckType.WEIGHT
].includes(checkType || template.check_type);
].includes(checkType);
// Update form when template changes
useEffect(() => {
if (template) {
reset({
name: template.name,
template_code: template.template_code || '',
check_type: template.check_type,
category: template.category || '',
description: template.description || '',
instructions: template.instructions || '',
is_active: template.is_active,
is_required: template.is_required,
is_critical: template.is_critical,
weight: template.weight,
min_value: template.min_value,
max_value: template.max_value,
target_value: template.target_value,
unit: template.unit || '',
tolerance_percentage: template.tolerance_percentage
});
setSelectedStages(template.applicable_stages || []);
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
const sections = getSections();
const field = sections[sectionIndex].fields[fieldIndex];
const fieldLabel = field.label;
// Map field labels to template properties
const fieldMap: Record<string, keyof QualityCheckTemplate | 'stages'> = {
'Nombre': 'name',
'Código de Plantilla': 'template_code',
'Tipo de Control': 'check_type',
'Categoría': 'category',
'Descripción': 'description',
'Instrucciones para el Personal': 'instructions',
'Valor Mínimo': 'min_value',
'Valor Máximo': 'max_value',
'Valor Objetivo': 'target_value',
'Unidad': 'unit',
'Tolerancia (%)': 'tolerance_percentage',
'Etapas Aplicables': 'stages',
'Peso en Puntuación General': 'weight',
'Plantilla Activa': 'is_active',
'Control Requerido': 'is_required',
'Control Crítico': 'is_critical'
};
const propertyName = fieldMap[fieldLabel];
if (propertyName) {
if (propertyName === 'stages') {
// Handle stages separately through the custom component
return;
}
// Convert string booleans to actual booleans
let processedValue: any = value;
if (propertyName === 'is_active' || propertyName === 'is_required' || propertyName === 'is_critical') {
processedValue = String(value) === 'true';
}
setEditedTemplate(prev => ({
...prev,
[propertyName]: processedValue
}));
}
}, [template, reset]);
const handleStageToggle = (stage: ProcessStage) => {
const newStages = selectedStages.includes(stage)
? selectedStages.filter(s => s !== stage)
: [...selectedStages, stage];
setSelectedStages(newStages);
};
const onSubmit = async (data: QualityCheckTemplateUpdate) => {
const handleSave = async () => {
try {
// Only include changed fields
// Build update object with only changed fields
const updates: QualityCheckTemplateUpdate = {};
Object.entries(data).forEach(([key, value]) => {
// Check each field for changes
Object.entries(editedTemplate).forEach(([key, value]) => {
const originalValue = (template as any)[key];
if (value !== originalValue) {
if (value !== originalValue && key !== 'id' && key !== 'created_at' && key !== 'updated_at' && key !== 'created_by') {
(updates as any)[key] = value;
}
});
@@ -155,309 +204,275 @@ export const EditQualityTemplateModal: React.FC<EditQualityTemplateModalProps> =
// Only submit if there are actual changes
if (Object.keys(updates).length > 0) {
await onUpdateTemplate(updates);
} else {
onClose();
}
setMode('view');
} catch (error) {
console.error('Error updating template:', error);
throw error;
}
};
const handleClose = () => {
reset();
const handleCancel = () => {
// Reset to original values
setEditedTemplate(template);
setSelectedStages(template.applicable_stages || []);
onClose();
setMode('view');
};
const getSections = (): EditViewModalSection[] => {
const sections: EditViewModalSection[] = [
{
title: 'Información Básica',
icon: ClipboardCheck,
fields: [
{
label: 'Nombre',
value: editedTemplate.name,
type: 'text',
highlight: true,
editable: true,
required: true,
placeholder: 'Ej: Control Visual de Pan'
},
{
label: 'Código de Plantilla',
value: editedTemplate.template_code || '',
type: 'text',
editable: true,
placeholder: 'Ej: CV_PAN_01'
},
{
label: 'Tipo de Control',
value: editedTemplate.check_type,
type: 'select',
editable: true,
required: true,
options: QUALITY_CHECK_TYPE_OPTIONS
},
{
label: 'Categoría',
value: editedTemplate.category || '',
type: 'select',
editable: true,
options: getCategoryOptions()
},
{
label: 'Descripción',
value: editedTemplate.description || '',
type: 'textarea',
editable: true,
placeholder: 'Describe qué evalúa esta plantilla de calidad',
span: 2
},
{
label: 'Instrucciones para el Personal',
value: editedTemplate.instructions || '',
type: 'textarea',
editable: true,
placeholder: 'Instrucciones detalladas para realizar este control de calidad',
span: 2,
helpText: 'Pasos específicos que debe seguir el operario'
}
]
}
];
// Add measurement configuration section if applicable
if (showMeasurementFields) {
sections.push({
title: 'Configuración de Medición',
icon: Target,
fields: [
{
label: 'Valor Mínimo',
value: editedTemplate.min_value || 0,
type: 'number',
editable: true,
placeholder: '0',
helpText: 'Valor mínimo aceptable para la medición'
},
{
label: 'Valor Máximo',
value: editedTemplate.max_value || 0,
type: 'number',
editable: true,
placeholder: '100',
helpText: 'Valor máximo aceptable para la medición'
},
{
label: 'Valor Objetivo',
value: editedTemplate.target_value || 0,
type: 'number',
editable: true,
placeholder: '50',
helpText: 'Valor ideal que se busca alcanzar'
},
{
label: 'Unidad',
value: editedTemplate.unit || '',
type: 'text',
editable: true,
placeholder: '°C / g / cm',
helpText: 'Unidad de medida (ej: °C para temperatura)'
},
{
label: 'Tolerancia (%)',
value: editedTemplate.tolerance_percentage || 0,
type: 'number',
editable: true,
placeholder: '5',
helpText: 'Porcentaje de tolerancia permitido'
}
]
});
}
// Process stages section with custom component
sections.push({
title: 'Etapas del Proceso',
icon: Settings,
description: 'Selecciona las etapas donde se debe aplicar este control. Si no seleccionas ninguna, se aplicará a todas las etapas.',
fields: [
{
label: 'Etapas Aplicables',
value: selectedStages.length > 0
? selectedStages.map(s => PROCESS_STAGE_OPTIONS.find(opt => opt.value === s)?.label || s).join(', ')
: 'Todas las etapas',
type: 'component',
editable: true,
component: ProcessStagesSelector,
componentProps: {
value: selectedStages,
onChange: setSelectedStages
},
span: 2
}
]
});
// Configuration section
sections.push({
title: 'Configuración Avanzada',
icon: Cog,
fields: [
{
label: 'Peso en Puntuación General',
value: editedTemplate.weight,
type: 'number',
editable: true,
placeholder: '1.0',
helpText: 'Mayor peso = mayor importancia en la puntuación final (0-10)',
validation: (value: string | number) => {
const num = Number(value);
return num < 0 || num > 10 ? 'El peso debe estar entre 0 y 10' : null;
}
},
{
label: 'Plantilla Activa',
value: editedTemplate.is_active,
type: 'select',
editable: true,
options: [
{ value: 'true', label: 'Sí' },
{ value: 'false', label: 'No' }
]
},
{
label: 'Control Requerido',
value: editedTemplate.is_required,
type: 'select',
editable: true,
options: [
{ value: 'true', label: 'Sí' },
{ value: 'false', label: 'No' }
],
helpText: 'Si es requerido, debe completarse obligatoriamente'
},
{
label: 'Control Crítico',
value: editedTemplate.is_critical,
type: 'select',
editable: true,
options: [
{ value: 'true', label: 'Sí' },
{ value: 'false', label: 'No' }
],
helpText: 'Si es crítico, bloquea la producción si falla'
}
]
});
// Template metadata section
sections.push({
title: 'Información de la Plantilla',
icon: ClipboardCheck,
collapsible: true,
collapsed: true,
fields: [
{
label: 'ID',
value: editedTemplate.id,
type: 'text',
editable: false
},
{
label: 'Creado',
value: editedTemplate.created_at,
type: 'datetime',
editable: false
},
{
label: 'Última Actualización',
value: editedTemplate.updated_at,
type: 'datetime',
editable: false
}
]
});
return sections;
};
const getStatusIndicator = () => {
const typeColors: Record<QualityCheckType, string> = {
[QualityCheckType.VISUAL]: '#3B82F6',
[QualityCheckType.MEASUREMENT]: '#10B981',
[QualityCheckType.TEMPERATURE]: '#EF4444',
[QualityCheckType.WEIGHT]: '#A855F7',
[QualityCheckType.BOOLEAN]: '#6B7280',
[QualityCheckType.TIMING]: '#F59E0B',
[QualityCheckType.CHECKLIST]: '#6366F1'
};
return {
color: editedTemplate.is_active ? typeColors[editedTemplate.check_type] : '#6B7280',
text: editedTemplate.is_active ? 'Activa' : 'Inactiva',
icon: Edit,
isCritical: editedTemplate.is_critical,
isHighlight: editedTemplate.is_required
};
};
return (
<Modal
<EditViewModal
isOpen={isOpen}
onClose={handleClose}
title={`Editar Plantilla: ${template.name}`}
onClose={onClose}
mode={mode}
onModeChange={setMode}
title={editedTemplate.name}
subtitle={`${getCategoryLabel(editedTemplate.category)}${editedTemplate.template_code || 'Sin código'}`}
statusIndicator={getStatusIndicator()}
sections={getSections()}
size="xl"
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Información Básica
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Nombre *
</label>
<Input
{...register('name', { required: 'El nombre es requerido' })}
placeholder="Ej: Control Visual de Pan"
error={errors.name?.message}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Código de Plantilla
</label>
<Input
{...register('template_code')}
placeholder="Ej: CV_PAN_01"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tipo de Control *
</label>
<Controller
name="check_type"
control={control}
render={({ field }) => (
<Select {...field} options={QUALITY_CHECK_TYPE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }))}>
{QUALITY_CHECK_TYPE_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Categoría
</label>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select {...field} options={[{ value: '', label: 'Seleccionar categoría' }, ...CATEGORY_OPTIONS]}>
<option value="">Seleccionar categoría</option>
{CATEGORY_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Descripción
</label>
<Textarea
{...register('description')}
placeholder="Describe qué evalúa esta plantilla de calidad"
rows={2}
/>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Instrucciones para el Personal
</label>
<Textarea
{...register('instructions')}
placeholder="Instrucciones detalladas para realizar este control de calidad"
rows={3}
/>
</div>
</Card>
{/* Measurement Configuration */}
{showMeasurementFields && (
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración de Medición
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Mínimo
</label>
<Input
type="number"
step="0.01"
{...register('min_value', { valueAsNumber: true })}
placeholder="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Máximo
</label>
<Input
type="number"
step="0.01"
{...register('max_value', { valueAsNumber: true })}
placeholder="100"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Objetivo
</label>
<Input
type="number"
step="0.01"
{...register('target_value', { valueAsNumber: true })}
placeholder="50"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Unidad
</label>
<Input
{...register('unit')}
placeholder={
checkType === QualityCheckType.TEMPERATURE ? '°C' :
checkType === QualityCheckType.WEIGHT ? 'g' : 'unidad'
}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tolerancia (%)
</label>
<Input
type="number"
step="0.1"
{...register('tolerance_percentage', { valueAsNumber: true })}
placeholder="5"
className="w-32"
/>
</div>
</Card>
)}
{/* Process Stages */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Etapas del Proceso Aplicables
</h4>
<p className="text-sm text-[var(--text-secondary)] mb-4">
Selecciona las etapas donde se debe aplicar este control. Si no seleccionas ninguna, se aplicará a todas las etapas.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{PROCESS_STAGE_OPTIONS.map(stage => (
<label
key={stage.value}
className="flex items-center space-x-2 p-2 border rounded cursor-pointer hover:bg-[var(--bg-secondary)]"
>
<input
type="checkbox"
checked={selectedStages.includes(stage.value)}
onChange={() => handleStageToggle(stage.value)}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">{stage.label}</span>
</label>
))}
</div>
</Card>
{/* Settings */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Peso en Puntuación General
</label>
<Input
type="number"
step="0.1"
min="0"
max="10"
{...register('weight', { valueAsNumber: true })}
placeholder="1.0"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Mayor peso = mayor importancia en la puntuación final
</p>
</div>
<div className="space-y-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_active')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Plantilla activa</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_required')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control requerido</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_critical')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control crítico</span>
<Badge variant="error" size="sm">
Bloquea producción si falla
</Badge>
</label>
</div>
</div>
</Card>
{/* Template Info */}
<Card className="p-4 bg-[var(--bg-secondary)]">
<h4 className="font-medium text-[var(--text-primary)] mb-2">
Información de la Plantilla
</h4>
<div className="text-sm text-[var(--text-secondary)] space-y-1">
<p>ID: {template.id}</p>
<p>Creado: {new Date(template.created_at).toLocaleDateString('es-ES')}</p>
<p>Última actualización: {new Date(template.updated_at).toLocaleDateString('es-ES')}</p>
</div>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
Cancelar
</Button>
<Button
type="submit"
variant="primary"
disabled={!isDirty || isLoading}
isLoading={isLoading}
>
Guardar Cambios
</Button>
</div>
</form>
</Modal>
loading={isLoading}
showDefaultActions={true}
onSave={handleSave}
onCancel={handleCancel}
onFieldChange={handleFieldChange}
mobileOptimized={true}
/>
);
};
};
export default EditQualityTemplateModal;

View File

@@ -44,6 +44,8 @@ import {
} from '../../../api/types/qualityTemplates';
import { CreateQualityTemplateModal } from './CreateQualityTemplateModal';
import { EditQualityTemplateModal } from './EditQualityTemplateModal';
import { ViewQualityTemplateModal } from './ViewQualityTemplateModal';
import { useTranslation } from 'react-i18next';
interface QualityTemplateManagerProps {
className?: string;
@@ -107,17 +109,28 @@ const PROCESS_STAGE_LABELS = {
export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
className = ''
}) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [selectedCheckType, setSelectedCheckType] = useState<QualityCheckType | ''>('');
const [selectedStage, setSelectedStage] = useState<ProcessStage | ''>('');
const [showActiveOnly, setShowActiveOnly] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// Helper function to get translated category label
const getCategoryLabel = (category: string | null | undefined): string => {
if (!category) return t('production.quality.categories.appearance', 'Sin categoría');
const translationKey = `production.quality.categories.${category}`;
const translated = t(translationKey);
// If translation is same as key, it means no translation exists, return the original
return translated === translationKey ? category : translated;
};
// API hooks
const {
data: templatesData,
@@ -344,7 +357,7 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
id={template.id}
statusIndicator={statusConfig}
title={template.name}
subtitle={template.category || 'Sin categoría'}
subtitle={getCategoryLabel(template.category)}
primaryValue={template.weight}
primaryValueLabel="peso"
secondaryInfo={{
@@ -366,7 +379,7 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
priority: 'primary',
onClick: () => {
setSelectedTemplate(template);
// Could open a view modal here
setShowViewModal(true);
}
},
{
@@ -387,25 +400,12 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
{
label: 'Eliminar',
icon: Trash2,
variant: 'danger',
destructive: true,
priority: 'secondary',
onClick: () => handleDeleteTemplate(template.id)
}
]}
>
{/* Additional badges */}
<div className="flex flex-wrap gap-1 mt-2">
{template.is_active && (
<Badge variant="success" size="sm">Activa</Badge>
)}
{template.is_required && (
<Badge variant="warning" size="sm">Requerida</Badge>
)}
{template.is_critical && (
<Badge variant="error" size="sm">Crítica</Badge>
)}
</div>
</StatusCard>
/>
);
})}
</div>
@@ -451,6 +451,22 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
isLoading={updateTemplateMutation.isPending}
/>
)}
{/* View Template Modal */}
{selectedTemplate && (
<ViewQualityTemplateModal
isOpen={showViewModal}
onClose={() => {
setShowViewModal(false);
setSelectedTemplate(null);
}}
template={selectedTemplate}
onEdit={() => {
setShowViewModal(false);
setShowEditModal(true);
}}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,331 @@
import React from 'react';
import { Eye, ClipboardCheck, Target, Settings, Cog, CheckCircle, AlertTriangle } from 'lucide-react';
import { EditViewModal, EditViewModalSection } from '../../ui/EditViewModal/EditViewModal';
import {
QualityCheckType,
ProcessStage,
type QualityCheckTemplate
} from '../../../api/types/qualityTemplates';
import { useTranslation } from 'react-i18next';
interface ViewQualityTemplateModalProps {
isOpen: boolean;
onClose: () => void;
template: QualityCheckTemplate;
onEdit?: () => void;
}
const QUALITY_CHECK_TYPE_LABELS: Record<QualityCheckType, string> = {
[QualityCheckType.VISUAL]: 'Visual - Inspección visual',
[QualityCheckType.MEASUREMENT]: 'Medición - Medidas precisas',
[QualityCheckType.TEMPERATURE]: 'Temperatura - Control térmico',
[QualityCheckType.WEIGHT]: 'Peso - Control de peso',
[QualityCheckType.BOOLEAN]: 'Sí/No - Verificación binaria',
[QualityCheckType.TIMING]: 'Tiempo - Control temporal',
[QualityCheckType.CHECKLIST]: 'Lista de verificación - Checklist'
};
const PROCESS_STAGE_LABELS: Record<ProcessStage, string> = {
[ProcessStage.MIXING]: 'Mezclado',
[ProcessStage.PROOFING]: 'Fermentación',
[ProcessStage.SHAPING]: 'Formado',
[ProcessStage.BAKING]: 'Horneado',
[ProcessStage.COOLING]: 'Enfriado',
[ProcessStage.PACKAGING]: 'Empaquetado',
[ProcessStage.FINISHING]: 'Acabado'
};
const CATEGORY_LABELS: Record<string, string> = {
'appearance': 'Apariencia',
'structure': 'Estructura',
'texture': 'Textura',
'flavor': 'Sabor',
'safety': 'Seguridad',
'packaging': 'Empaque',
'temperature': 'Temperatura',
'weight': 'Peso',
'dimensions': 'Dimensiones'
};
export const ViewQualityTemplateModal: React.FC<ViewQualityTemplateModalProps> = ({
isOpen,
onClose,
template,
onEdit
}) => {
const { t } = useTranslation();
// Helper function to get translated category label
const getCategoryLabel = (category: string | null | undefined): string => {
if (!category) return 'Sin categoría';
const translationKey = `production.quality.categories.${category}`;
const translated = t(translationKey);
// If translation is same as key, it means no translation exists, fallback to hardcoded labels
return translated === translationKey ? (CATEGORY_LABELS[category] || category) : translated;
};
const showMeasurementFields = [
QualityCheckType.MEASUREMENT,
QualityCheckType.TEMPERATURE,
QualityCheckType.WEIGHT
].includes(template.check_type);
const getSections = (): EditViewModalSection[] => {
const sections: EditViewModalSection[] = [
{
title: 'Información Básica',
icon: ClipboardCheck,
fields: [
{
label: 'Nombre',
value: template.name,
type: 'text',
highlight: true,
editable: false
},
{
label: 'Código de Plantilla',
value: template.template_code || 'No especificado',
type: 'text',
editable: false
},
{
label: 'Tipo de Control',
value: QUALITY_CHECK_TYPE_LABELS[template.check_type],
type: 'text',
editable: false
},
{
label: 'Categoría',
value: getCategoryLabel(template.category),
type: 'text',
editable: false
},
{
label: 'Descripción',
value: template.description || 'Sin descripción',
type: 'text',
editable: false,
span: 2 as 1 | 2 | 3
},
{
label: 'Instrucciones para el Personal',
value: template.instructions || 'No especificadas',
type: 'text',
editable: false,
span: 2 as 1 | 2 | 3
}
]
}
];
// Add measurement configuration section if applicable
if (showMeasurementFields) {
const measurementFields = [];
if (template.min_value !== null && template.min_value !== undefined) {
measurementFields.push({
label: 'Valor Mínimo',
value: `${template.min_value} ${template.unit || ''}`,
type: 'text' as const,
editable: false
});
}
if (template.max_value !== null && template.max_value !== undefined) {
measurementFields.push({
label: 'Valor Máximo',
value: `${template.max_value} ${template.unit || ''}`,
type: 'text' as const,
editable: false
});
}
if (template.target_value !== null && template.target_value !== undefined) {
measurementFields.push({
label: 'Valor Objetivo',
value: `${template.target_value} ${template.unit || ''}`,
type: 'text' as const,
editable: false,
highlight: true
});
}
if (template.tolerance_percentage !== null && template.tolerance_percentage !== undefined) {
measurementFields.push({
label: 'Tolerancia',
value: `±${template.tolerance_percentage}%`,
type: 'text' as const,
editable: false
});
}
if (template.unit) {
measurementFields.push({
label: 'Unidad de Medida',
value: template.unit,
type: 'text' as const,
editable: false
});
}
// Add summary field if we have min, max, and target
if (template.min_value !== null && template.max_value !== null && template.target_value !== null) {
measurementFields.push({
label: 'Rango Aceptable',
value: `${template.min_value} - ${template.max_value} ${template.unit || ''} (objetivo: ${template.target_value} ${template.unit || ''})`,
type: 'text' as const,
editable: false,
span: 2 as 1 | 2 | 3
});
}
if (measurementFields.length > 0) {
sections.push({
title: 'Configuración de Medición',
icon: Target,
fields: measurementFields
});
}
}
// Process stages section
sections.push({
title: 'Etapas del Proceso Aplicables',
icon: Settings,
description: template.applicable_stages && template.applicable_stages.length > 0
? 'Este control se aplica a las siguientes etapas del proceso:'
: 'Este control se aplica a todas las etapas del proceso',
fields: [
{
label: 'Etapas',
value: template.applicable_stages && template.applicable_stages.length > 0
? template.applicable_stages.map(stage => PROCESS_STAGE_LABELS[stage]).join(', ')
: 'Todas las etapas',
type: 'text',
editable: false,
span: 2 as 1 | 2 | 3
}
]
});
// Configuration section
sections.push({
title: 'Configuración',
icon: Cog,
fields: [
{
label: 'Peso en Puntuación General',
value: template.weight.toString(),
type: 'text',
editable: false,
helpText: 'Mayor peso = mayor importancia en la puntuación final'
},
{
label: 'Estado de la Plantilla',
value: template.is_active ? '✓ Activa' : '✗ Inactiva',
type: 'status',
editable: false
},
{
label: 'Tipo de Control',
value: template.is_required ? '✓ Requerido' : 'Opcional',
type: 'status',
editable: false,
helpText: template.is_required ? 'Debe completarse obligatoriamente' : undefined
},
{
label: 'Nivel de Criticidad',
value: template.is_critical ? '⚠️ Crítico - Bloquea producción si falla' : 'Normal',
type: 'status',
editable: false,
highlight: template.is_critical
}
]
});
// Template metadata section
sections.push({
title: 'Información de la Plantilla',
icon: ClipboardCheck,
collapsible: true,
collapsed: true,
fields: [
{
label: 'ID',
value: template.id,
type: 'text',
editable: false
},
{
label: 'Fecha de Creación',
value: template.created_at,
type: 'datetime',
editable: false
},
{
label: 'Última Actualización',
value: template.updated_at,
type: 'datetime',
editable: false
}
]
});
return sections;
};
const getStatusIndicator = () => {
const typeColors: Record<QualityCheckType, string> = {
[QualityCheckType.VISUAL]: '#3B82F6',
[QualityCheckType.MEASUREMENT]: '#10B981',
[QualityCheckType.TEMPERATURE]: '#EF4444',
[QualityCheckType.WEIGHT]: '#A855F7',
[QualityCheckType.BOOLEAN]: '#6B7280',
[QualityCheckType.TIMING]: '#F59E0B',
[QualityCheckType.CHECKLIST]: '#6366F1'
};
const getIcon = () => {
if (template.is_critical) return AlertTriangle;
if (template.is_active) return CheckCircle;
return Eye;
};
return {
color: template.is_active ? typeColors[template.check_type] : '#6B7280',
text: template.is_active ? 'Activa' : 'Inactiva',
icon: getIcon(),
isCritical: template.is_critical,
isHighlight: template.is_required
};
};
// Custom actions for view mode
const customActions = onEdit ? [
{
label: 'Editar Plantilla',
icon: Cog,
variant: 'primary' as const,
onClick: onEdit
}
] : [];
return (
<EditViewModal
isOpen={isOpen}
onClose={onClose}
mode="view"
title={template.name}
subtitle={`${getCategoryLabel(template.category)}${template.template_code || 'Sin código'}`}
statusIndicator={getStatusIndicator()}
sections={getSections()}
size="xl"
showDefaultActions={false}
actions={customActions}
mobileOptimized={true}
/>
);
};
export default ViewQualityTemplateModal;

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Plus, Users, Shield } from 'lucide-react';
import React, { useState, useMemo } from 'react';
import { Plus, Users, Shield, UserPlus } from 'lucide-react';
import { AddModal } from '../../ui/AddModal/AddModal';
import { TENANT_ROLES } from '../../../types/roles';
import { statusColors } from '../../../styles/colors';
@@ -7,13 +7,13 @@ import { statusColors } from '../../../styles/colors';
interface AddTeamMemberModalProps {
isOpen: boolean;
onClose: () => void;
onAddMember?: (userData: { userId: string; role: string }) => Promise<void>;
onAddMember?: (userData: { userId?: string; role: string; createUser?: boolean; email?: string; fullName?: string; password?: string; phone?: string }) => Promise<void>;
availableUsers: Array<{ id: string; full_name: string; email: string }>;
}
/**
* AddTeamMemberModal - Modal for adding a new member to the team
* Comprehensive form for adding new users to the bakery team
* Supports both adding existing users and creating new users (pilot phase)
*/
export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
isOpen,
@@ -22,11 +22,30 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
availableUsers
}) => {
const [loading, setLoading] = useState(false);
const [createNewUser, setCreateNewUser] = useState(false);
const handleSave = async (formData: Record<string, any>) => {
// Validation - need either userId OR userEmail
if (!formData.userId && !formData.userEmail) {
throw new Error('Por favor selecciona un usuario o ingresa un email');
const isCreatingNewUser = formData.createNewUser === true || formData.createNewUser === 'true';
// Validation based on mode
if (isCreatingNewUser) {
// Creating new user - validate required fields
if (!formData.email || !formData.fullName || !formData.password) {
throw new Error('Por favor completa todos los campos requeridos: email, nombre completo y contraseña');
}
if (formData.password !== formData.confirmPassword) {
throw new Error('Las contraseñas no coinciden');
}
if (formData.password.length < 8) {
throw new Error('La contraseña debe tener al menos 8 caracteres');
}
} else {
// Adding existing user - validate userId
if (!formData.userId) {
throw new Error('Por favor selecciona un usuario');
}
}
if (!formData.role) {
@@ -37,8 +56,13 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
try {
if (onAddMember) {
await onAddMember({
userId: formData.userId || formData.userEmail, // Use email as userId if no userId selected
role: formData.role
userId: isCreatingNewUser ? undefined : formData.userId,
role: formData.role,
createUser: isCreatingNewUser,
email: isCreatingNewUser ? formData.email : undefined,
fullName: isCreatingNewUser ? formData.fullName : undefined,
password: isCreatingNewUser ? formData.password : undefined,
phone: isCreatingNewUser ? formData.phone : undefined,
});
}
} catch (error) {
@@ -70,57 +94,125 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
}))
: [];
// Create fields array conditionally based on available users
const userFields = [];
// Use useMemo to recalculate sections when createNewUser changes
const sections = useMemo(() => {
const userFields = [];
// Add user selection field if we have users available
if (userOptions.length > 0) {
// Add mode toggle
userFields.push({
label: 'Usuario',
name: 'userId',
label: 'Modo de Adición',
name: 'createNewUser',
type: 'select' as const,
options: userOptions,
placeholder: 'Seleccionar usuario...',
helpText: 'Selecciona un usuario existente o ingresa un email manualmente abajo'
required: true,
options: [
{ label: 'Agregar Usuario Existente', value: 'false' },
{ label: 'Crear Nuevo Usuario', value: 'true' }
],
defaultValue: 'false',
helpText: 'Selecciona si quieres agregar un usuario existente o crear uno nuevo'
});
}
// Add email field (always present)
userFields.push({
label: userOptions.length > 0 ? 'O Email del Usuario' : 'Email del Usuario',
name: 'userEmail',
type: 'email' as const,
placeholder: 'usuario@ejemplo.com',
helpText: userOptions.length > 0
? 'Alternativamente, ingresa el email de un usuario nuevo'
: 'Ingresa el email del usuario que quieres agregar',
validation: (value: string | number) => {
const email = String(value);
if (email && !email.includes('@')) {
return 'Por favor ingresa un email válido';
// Conditional fields based on create mode
if (createNewUser) {
// Fields for creating new user
userFields.push(
{
label: 'Email',
name: 'email',
type: 'email' as const,
required: true,
placeholder: 'usuario@ejemplo.com',
helpText: 'Email del nuevo usuario (se usará para iniciar sesión)',
validation: (value: string | number) => {
const email = String(value);
if (!email || !email.includes('@')) {
return 'Por favor ingresa un email válido';
}
return null;
}
},
{
label: 'Nombre Completo',
name: 'fullName',
type: 'text' as const,
required: true,
placeholder: 'Juan Pérez',
helpText: 'Nombre completo del nuevo usuario'
},
{
label: 'Contraseña',
name: 'password',
type: 'password' as const,
required: true,
placeholder: '••••••••',
helpText: 'Contraseña inicial (mínimo 8 caracteres)',
validation: (value: string | number) => {
const pwd = String(value);
if (pwd && pwd.length < 8) {
return 'La contraseña debe tener al menos 8 caracteres';
}
return null;
}
},
{
label: 'Confirmar Contraseña',
name: 'confirmPassword',
type: 'password' as const,
required: true,
placeholder: '••••••••',
helpText: 'Repite la contraseña'
},
{
label: 'Teléfono (opcional)',
name: 'phone',
type: 'tel' as const,
placeholder: '+34 600 000 000',
helpText: 'Número de teléfono del usuario'
}
);
} else {
// Field for selecting existing user
if (userOptions.length > 0) {
userFields.push({
label: 'Usuario',
name: 'userId',
type: 'select' as const,
required: true,
options: userOptions,
placeholder: 'Seleccionar usuario...',
helpText: 'Selecciona el usuario que quieres agregar al equipo'
});
} else {
userFields.push({
label: 'No hay usuarios disponibles',
name: 'noUsers',
type: 'text' as const,
disabled: true,
defaultValue: 'No hay usuarios disponibles para agregar. Crea un nuevo usuario.',
helpText: 'Cambia el modo a "Crear Nuevo Usuario" para agregar miembros'
});
}
return null;
}
});
// Add role field
userFields.push({
label: 'Rol',
name: 'role',
type: 'select' as const,
required: true,
options: roleOptions,
defaultValue: TENANT_ROLES.MEMBER,
helpText: 'Selecciona el nivel de acceso para este usuario'
});
// Add role field (common to both modes)
userFields.push({
label: 'Rol en el Equipo',
name: 'role',
type: 'select' as const,
required: true,
options: roleOptions,
defaultValue: TENANT_ROLES.MEMBER,
helpText: 'Selecciona el nivel de acceso para este usuario'
});
const sections = [
{
title: 'Información del Miembro',
icon: Users,
fields: userFields
}
];
return [
{
title: createNewUser ? 'Crear Nuevo Usuario' : 'Seleccionar Usuario',
icon: createNewUser ? UserPlus : Users,
fields: userFields
}
];
}, [createNewUser, userOptions, roleOptions]);
return (
<AddModal
@@ -133,6 +225,12 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
size="lg"
loading={loading}
onSave={handleSave}
onFieldChange={(fieldName, value) => {
// Handle the mode toggle field change
if (fieldName === 'createNewUser') {
setCreateNewUser(value === 'true');
}
}}
/>
);
};

View File

@@ -278,13 +278,63 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
setSearchValue(e.target.value);
}, []);
// Filter navigation items based on search
const filteredItems = useMemo(() => {
if (!searchValue.trim()) {
return visibleItems;
}
const searchLower = searchValue.toLowerCase();
const filterItems = (items: NavigationItem[]): NavigationItem[] => {
return items.filter(item => {
// Check if item label matches
const labelMatches = item.label.toLowerCase().includes(searchLower);
// Check if any child matches
if (item.children && item.children.length > 0) {
const childrenMatch = item.children.some(child =>
child.label.toLowerCase().includes(searchLower)
);
return labelMatches || childrenMatch;
}
return labelMatches;
}).map(item => {
// If item has children, filter them too
if (item.children && item.children.length > 0) {
return {
...item,
children: item.children.filter(child =>
child.label.toLowerCase().includes(searchLower)
)
};
}
return item;
});
};
return filterItems(visibleItems);
}, [visibleItems, searchValue]);
const handleSearchSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (searchValue.trim()) {
// TODO: Implement search functionality
console.log('Search:', searchValue);
// Search is now live-filtered as user types
if (searchValue.trim() && filteredItems.length > 0) {
// Navigate to first matching item
const firstItem = filteredItems[0];
if (firstItem.to) {
navigate(firstItem.to);
setSearchValue('');
} else if (firstItem.children && firstItem.children.length > 0) {
const firstChild = firstItem.children[0];
if (firstChild.to) {
navigate(firstChild.to);
setSearchValue('');
}
}
}
}, [searchValue]);
}, [searchValue, filteredItems, navigate]);
const clearSearch = useCallback(() => {
setSearchValue('');
@@ -734,7 +784,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
{/* Navigation */}
<nav className={clsx('flex-1 overflow-y-auto overflow-x-hidden', isCollapsed ? 'px-1 py-4' : 'p-4')}>
<ul className={clsx(isCollapsed ? 'space-y-1 flex flex-col items-center' : 'space-y-2')}>
{visibleItems.map(item => renderItem(item))}
{filteredItems.map(item => renderItem(item))}
</ul>
</nav>
@@ -785,14 +835,14 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
<div className="py-1">
<button
onClick={() => {
navigate('/app/settings/personal-info');
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"
>
<User className="h-4 w-4" />
{t('common:profile.my_profile', 'Mi perfil')}
<Settings className="h-4 w-4" />
{t('common:profile.settings', 'Ajustes')}
</button>
<button
onClick={() => {
@@ -934,7 +984,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
{/* Navigation */}
<nav className="flex-1 p-4 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
<ul className="space-y-2 pb-4">
{visibleItems.map(item => renderItem(item))}
{filteredItems.map(item => renderItem(item))}
</ul>
</nav>
@@ -975,17 +1025,6 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
{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" />
{t('common:profile.profile', 'Perfil')}
</button>
<button
onClick={() => {
navigate('/app/settings');
@@ -995,7 +1034,18 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
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" />
{t('common:profile.settings', 'Configuración')}
{t('common:profile.settings', 'Ajustes')}
</button>
<button
onClick={() => {
navigate('/app/settings/organizations');
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"
>
<Factory className="h-4 w-4" />
{t('common:profile.my_locations', 'Mis Locales')}
</button>
</div>

View File

@@ -185,6 +185,60 @@
"november": "November",
"december": "December"
},
"fields": {
"name": "Name",
"contact_person": "Contact Person",
"email": "Email",
"phone": "Phone",
"city": "City",
"country": "Country",
"address": "Address",
"postal_code": "Postal Code",
"region": "Region",
"state": "State",
"company": "Company",
"position": "Position",
"department": "Department",
"title": "Title",
"description": "Description",
"notes": "Notes",
"status": "Status",
"type": "Type",
"category": "Category",
"priority": "Priority",
"date": "Date",
"time": "Time",
"amount": "Amount",
"quantity": "Quantity",
"price": "Price",
"cost": "Cost",
"total": "Total",
"discount": "Discount",
"tax": "Tax",
"currency": "Currency",
"reference": "Reference",
"code": "Code",
"id": "ID",
"created_at": "Created Date",
"updated_at": "Updated Date",
"due_date": "Due Date",
"start_date": "Start Date",
"end_date": "End Date",
"duration": "Duration",
"percentage": "Percentage",
"rate": "Rate",
"score": "Score",
"rating": "Rating",
"version": "Version",
"version_number": "Version Number",
"version_date": "Version Date",
"version_notes": "Version Notes",
"version_status": "Version Status",
"version_type": "Version Type",
"version_category": "Version Category",
"version_priority": "Version Priority",
"version_description": "Version Description"
},
"forms": {
"required": "Required",
"optional": "Optional",
@@ -356,4 +410,4 @@
"language": "Language",
"open_menu": "Open navigation menu"
}
}
}

View File

@@ -288,13 +288,19 @@
}
},
"grants": {
"eu_horizon": "EU Horizon Europe",
"eu_horizon_req": "Requires 30% reduction",
"farm_to_fork": "Farm to Fork",
"farm_to_fork_req": "Requires 20% reduction",
"circular_economy": "Circular Economy",
"circular_economy_req": "Requires 15% reduction",
"un_sdg": "UN SDG Certified",
"life_circular_economy": "LIFE Programme - Circular Economy",
"life_circular_economy_req": "Requires 15% reduction",
"life_circular_economy_funding": "€73M available",
"horizon_europe_cluster_6": "Horizon Europe Cluster 6",
"horizon_europe_cluster_6_req": "Requires 20% reduction",
"horizon_europe_cluster_6_funding": "€880M+ annually",
"fedima_sustainability_grant": "Fedima Sustainability Grant",
"fedima_sustainability_grant_req": "Requires 15% reduction",
"fedima_sustainability_grant_funding": "€20,000 per award",
"eit_food_retail": "EIT Food - Retail Innovation",
"eit_food_retail_req": "Requires 20% reduction",
"eit_food_retail_funding": "€15-45k per project",
"un_sdg": "UN SDG 12.3 Certification",
"un_sdg_req": "Requires 50% reduction",
"eligible": "Eligible",
"on_track": "On Track"

View File

@@ -87,6 +87,24 @@
"subtitle": "Monitor quality metrics and trends",
"error": "Error loading quality data"
},
"categories": {
"weight_check": "Weight Control",
"temperature_check": "Temperature Control",
"moisture_check": "Moisture Control",
"volume_check": "Volume Control",
"appearance": "Appearance",
"structure": "Structure",
"texture": "Texture",
"flavor": "Flavor",
"safety": "Safety",
"packaging": "Packaging",
"temperature": "Temperature",
"weight": "Weight",
"dimensions": "Dimensions",
"time_check": "Time Control",
"chemical": "Chemical",
"hygiene": "Hygiene"
},
"inspection": {
"title": "Quality Inspection",
"notes_placeholder": "Add notes for this criteria (optional)..."

View File

@@ -1,7 +1,85 @@
{
"bakery": {
"title": "Bakery Settings",
"description": "Configure your bakery information and operational settings",
"tabs": {
"information": "Information",
"hours": "Hours",
"operations": "Operational Settings"
},
"information": {
"title": "General Information",
"description": "Basic data and preferences for your bakery",
"general_section": "General Information",
"location_section": "Location",
"business_section": "Business Data",
"fields": {
"name": "Bakery Name",
"description": "Description",
"email": "Contact Email",
"phone": "Phone",
"website": "Website",
"address": "Address",
"city": "City",
"postal_code": "Postal Code",
"country": "Country",
"tax_id": "Tax ID",
"currency": "Currency",
"timezone": "Timezone",
"language": "Language"
},
"placeholders": {
"name": "Your bakery name",
"email": "contact@bakery.com",
"phone": "+1 555 123 4567",
"website": "https://your-bakery.com",
"address": "Street, number, etc.",
"city": "City",
"postal_code": "12345",
"country": "Country",
"tax_id": "123456789",
"description": "Describe your bakery..."
}
},
"hours": {
"title": "Operating Hours",
"description": "Configure your bakery's hours",
"days": {
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
},
"closed": "Closed",
"closed_all_day": "Closed all day",
"open_time": "Open",
"close_time": "Close"
},
"operations": {
"title": "Operational Settings",
"description": "Configure your bakery's operational parameters",
"procurement": "Procurement Management",
"inventory": "Inventory Management",
"production": "Production Management",
"suppliers": "Supplier Management",
"pos": "Point of Sale",
"orders": "Order Management"
},
"unsaved_changes": "You have unsaved changes",
"save_success": "Information updated successfully",
"save_error": "Error updating information"
},
"profile": {
"title": "User Profile",
"title": "Settings",
"description": "Manage your personal information and preferences",
"tabs": {
"personal": "Information",
"notifications": "Notifications",
"privacy": "Privacy"
},
"personal_info": "Personal Information",
"edit_profile": "Edit Profile",
"change_password": "Change Password",
@@ -19,11 +97,55 @@
"avatar": "Avatar"
},
"password": {
"title": "Change Password",
"current_password": "Current Password",
"new_password": "New Password",
"confirm_password": "Confirm Password",
"change_password": "Change Password",
"password_requirements": "Password must be at least 8 characters long"
"password_requirements": "Password must be at least 8 characters long",
"change_success": "Password updated successfully",
"change_error": "Could not change your password"
},
"notifications": {
"title": "Notification Preferences",
"description": "Configure how and when you receive notifications",
"contact_info": "Contact Information",
"global_settings": "Global Settings",
"channel_controls": "Channel Controls",
"categories": {
"alerts": "Alerts",
"reports": "Reports",
"marketing": "Marketing"
},
"channels": {
"email": "Email",
"push": "Push",
"whatsapp": "WhatsApp"
},
"quiet_hours": "Quiet Hours",
"quiet_hours_description": "No notifications during this period",
"digest_frequency": "Digest Frequency",
"email_limit": "Daily limit",
"preferences_saved": "Preferences saved successfully",
"preferences_error": "Error saving preferences"
},
"privacy": {
"title": "Privacy & Data",
"description": "Manage your privacy and personal data",
"gdpr_rights": "Your Data Rights",
"gdpr_description": "Under GDPR, you have the right to access, export, and delete your personal data",
"export_data": "Export Your Data",
"export_description": "Download a copy of all your personal data in JSON format",
"export_button": "Export My Data",
"export_success": "Your data has been exported successfully",
"export_error": "Failed to export your data. Please try again",
"delete_account": "Delete Account",
"delete_description": "Permanently delete your account and all associated data",
"delete_button": "Delete My Account",
"delete_warning": "This action cannot be undone",
"cookie_preferences": "Cookie Preferences",
"privacy_policy": "Privacy Policy",
"terms": "Terms of Service"
}
},
"team": {
@@ -43,14 +165,6 @@
"switch_organization": "Switch Organization",
"create_organization": "Create Organization"
},
"bakery_config": {
"title": "Bakery Configuration",
"description": "Configure your bakery-specific settings",
"general": "General",
"products": "Products",
"hours": "Operating Hours",
"notifications": "Notifications"
},
"subscription": {
"title": "Subscription",
"description": "Manage your subscription plan",
@@ -60,32 +174,19 @@
"upgrade": "Upgrade Plan",
"manage": "Manage Subscription"
},
"communication": {
"title": "Communication Preferences",
"description": "Configure how and when you receive notifications",
"email_notifications": "Email Notifications",
"push_notifications": "Push Notifications",
"sms_notifications": "SMS Notifications",
"marketing": "Marketing Communications",
"alerts": "System Alerts"
},
"tabs": {
"profile": "Profile",
"team": "Team",
"organization": "Organization",
"bakery_config": "Configuration",
"subscription": "Subscription",
"communication": "Communication"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"discard": "Discard",
"edit": "Edit",
"delete": "Delete",
"loading": "Loading...",
"saving": "Saving...",
"success": "Success",
"error": "Error",
"required": "Required",
"optional": "Optional"
"optional": "Optional",
"reset": "Reset",
"reset_all": "Reset All"
}
}

View File

@@ -73,7 +73,27 @@
"delivery_status": "Delivery Status",
"quality_rating": "Quality Rating",
"delivery_rating": "Delivery Rating",
"invoice_status": "Invoice Status"
"invoice_status": "Invoice Status",
"supplier_code": "Supplier Code",
"lead_time": "Lead Time (days)",
"minimum_order": "Minimum Order",
"credit_limit": "Credit Limit",
"currency": "Currency",
"created_date": "Created Date",
"updated_date": "Last Updated",
"notes": "Notes"
},
"sections": {
"contact_info": "Contact Information",
"commercial_info": "Commercial Information",
"performance": "Performance and Statistics",
"notes": "Notes"
},
"placeholders": {
"name": "Supplier name",
"contact_person": "Contact person name",
"supplier_code": "Unique code",
"notes": "Notes about the supplier"
},
"descriptions": {
"supplier_type": "Select the type of products or services this supplier offers",
@@ -81,4 +101,4 @@
"quality_rating": "1 to 5 star rating based on product quality",
"delivery_rating": "1 to 5 star rating based on delivery punctuality and condition"
}
}
}

View File

@@ -51,10 +51,40 @@
"title": "Grant Program Eligibility",
"overall_readiness": "Overall Readiness",
"programs": {
"eu_horizon_europe": "EU Horizon Europe",
"eu_farm_to_fork": "EU Farm to Fork",
"national_circular_economy": "Circular Economy Grants",
"un_sdg_certified": "UN SDG Certification"
"life_circular_economy": "LIFE Programme - Circular Economy",
"life_circular_economy_description": "EU LIFE Programme supporting circular economy initiatives for food waste reduction",
"life_circular_economy_funding": "€73M available for circular economy projects",
"life_circular_economy_deadline": "Application deadline: September 23, 2025",
"life_circular_economy_requirement": "Requires 15% waste reduction from baseline",
"life_circular_economy_link": "https://cinea.ec.europa.eu/life-calls-proposals-2025_en",
"horizon_europe_cluster_6": "Horizon Europe - Cluster 6 Food Systems",
"horizon_europe_cluster_6_description": "R&I funding for sustainable food systems, bioeconomy, and waste reduction",
"horizon_europe_cluster_6_funding": "€880M+ annually for food systems projects",
"horizon_europe_cluster_6_deadline": "Rolling calls throughout 2025",
"horizon_europe_cluster_6_requirement": "Requires 20% waste reduction from baseline",
"horizon_europe_cluster_6_link": "https://research-and-innovation.ec.europa.eu/funding/cluster-6",
"fedima_sustainability_grant": "Fedima Sustainability Grant",
"fedima_sustainability_grant_description": "Bi-annual grant for local bakery sustainability initiatives",
"fedima_sustainability_grant_funding": "€20,000 per award",
"fedima_sustainability_grant_deadline": "Next deadline: June 30, 2025",
"fedima_sustainability_grant_requirement": "Requires 15% waste reduction (bakery-specific)",
"fedima_sustainability_grant_link": "https://grant.fedima.org",
"eit_food_retail": "EIT Food - Retail Innovation",
"eit_food_retail_description": "Support for retail food product launches addressing critical challenges",
"eit_food_retail_funding": "€15,000 - €45,000 per project",
"eit_food_retail_deadline": "Rolling applications",
"eit_food_retail_requirement": "Requires 20% waste reduction and retail innovation",
"eit_food_retail_link": "https://www.eitfood.eu/funding",
"un_sdg_certified": "UN SDG 12.3 Certification",
"un_sdg_certified_description": "Official certification for achieving SDG 12.3 targets",
"un_sdg_certified_funding": "Certification (not funding)",
"un_sdg_certified_deadline": "Ongoing certification process",
"un_sdg_certified_requirement": "Requires 50% waste reduction from baseline",
"un_sdg_certified_link": "https://sdgs.un.org/goals/goal12"
},
"confidence": {
"high": "High Confidence",
@@ -65,6 +95,11 @@
"eligible": "Eligible",
"not_eligible": "Not Eligible",
"requirements_met": "Requirements Met"
},
"spain_compliance": {
"title": "Spain-Specific Compliance",
"law_1_2025": "Spanish Law 1/2025 on Food Waste Prevention",
"circular_economy_strategy": "Spanish Circular Economy Strategy 2030"
}
},
"waste": {
@@ -84,10 +119,11 @@
"export_error": "Failed to export report",
"types": {
"general": "General Sustainability Report",
"eu_horizon": "EU Horizon Europe Format",
"farm_to_fork": "Farm to Fork Report",
"circular_economy": "Circular Economy Report",
"un_sdg": "UN SDG Certification Report"
"life_circular_economy": "LIFE Programme - Circular Economy Application",
"horizon_europe_cluster_6": "Horizon Europe Cluster 6 Application",
"fedima_sustainability_grant": "Fedima Sustainability Grant Application",
"eit_food_retail": "EIT Food Retail Innovation Application",
"un_sdg": "UN SDG 12.3 Certification Report"
}
}
}

View File

@@ -185,6 +185,84 @@
"november": "Noviembre",
"december": "Diciembre"
},
"fields": {
"name": "Nombre",
"contact_person": "Persona de Contacto",
"email": "Email",
"phone": "Teléfono",
"city": "Ciudad",
"country": "País",
"address": "Dirección",
"postal_code": "Código Postal",
"region": "Región",
"state": "Estado",
"company": "Empresa",
"position": "Cargo",
"department": "Departamento",
"title": "Título",
"description": "Descripción",
"notes": "Notas",
"status": "Estado",
"type": "Tipo",
"category": "Categoría",
"priority": "Prioridad",
"date": "Fecha",
"time": "Hora",
"amount": "Monto",
"quantity": "Cantidad",
"price": "Precio",
"cost": "Costo",
"total": "Total",
"discount": "Descuento",
"tax": "Impuesto",
"currency": "Moneda",
"reference": "Referencia",
"code": "Código",
"id": "ID",
"created_at": "Fecha de Creación",
"updated_at": "Fecha de Actualización",
"due_date": "Fecha de Vencimiento",
"start_date": "Fecha de Inicio",
"end_date": "Fecha de Fin",
"duration": "Duración",
"percentage": "Porcentaje",
"rate": "Tasa",
"score": "Puntuación",
"rating": "Calificación",
"version": "Versión",
"version_number": "Número de Versión",
"version_date": "Fecha de Versión",
"version_notes": "Notas de Versión",
"version_status": "Estado de Versión",
"version_type": "Tipo de Versión",
"version_category": "Categoría de Versión",
"version_priority": "Prioridad de Versión",
"version_description": "Descripción de Versión",
"version_notes_placeholder": "Notas sobre esta versión...",
"version_status_placeholder": "Seleccionar estado de versión...",
"version_type_placeholder": "Seleccionar tipo de versión...",
"version_category_placeholder": "Seleccionar categoría de versión...",
"version_priority_placeholder": "Seleccionar prioridad de versión...",
"version_description_placeholder": "Describir los cambios de esta versión...",
"version_number_placeholder": "Ingresar número de versión...",
"version_date_placeholder": "Seleccionar fecha de versión...",
"version_notes_label": "Notas de Versión",
"version_status_label": "Estado de Versión",
"version_type_label": "Tipo de Versión",
"version_category_label": "Categoría de Versión",
"version_priority_label": "Prioridad de Versión",
"version_description_label": "Descripción de Versión",
"version_number_label": "Número de Versión",
"version_date_label": "Fecha de Versión",
"version_notes_help": "Notas adicionales sobre los cambios en esta versión",
"version_status_help": "Estado actual de esta versión",
"version_type_help": "Tipo de cambio realizado en esta versión",
"version_category_help": "Categoría de funcionalidad de esta versión",
"version_priority_help": "Prioridad de implementación de esta versión",
"version_description_help": "Descripción detallada de los cambios en esta versión",
"version_number_help": "Número de versión (ej: 1.0.0)",
"version_date_help": "Fecha de lanzamiento de esta versión"
},
"forms": {
"required": "Requerido",
"optional": "Opcional",
@@ -356,4 +434,4 @@
"language": "Idioma",
"open_menu": "Abrir menú de navegación"
}
}
}

View File

@@ -288,13 +288,19 @@
}
},
"grants": {
"eu_horizon": "Horizonte Europa UE",
"eu_horizon_req": "Requiere reducción del 30%",
"farm_to_fork": "De la Granja a la Mesa",
"farm_to_fork_req": "Requiere reducción del 20%",
"circular_economy": "Economía Circular",
"circular_economy_req": "Requiere reducción del 15%",
"un_sdg": "Certificado ODS ONU",
"life_circular_economy": "Programa LIFE - Economía Circular",
"life_circular_economy_req": "Requiere reducción del 15%",
"life_circular_economy_funding": "€73M disponibles",
"horizon_europe_cluster_6": "Horizonte Europa Cluster 6",
"horizon_europe_cluster_6_req": "Requiere reducción del 20%",
"horizon_europe_cluster_6_funding": "€880M+ anuales",
"fedima_sustainability_grant": "Subvención Sostenibilidad Fedima",
"fedima_sustainability_grant_req": "Requiere reducción del 15%",
"fedima_sustainability_grant_funding": "€20.000 por proyecto",
"eit_food_retail": "EIT Food - Innovación Retail",
"eit_food_retail_req": "Requiere reducción del 20%",
"eit_food_retail_funding": "€15-45k por proyecto",
"un_sdg": "Certificación ODS 12.3 ONU",
"un_sdg_req": "Requiere reducción del 50%",
"eligible": "Elegible",
"on_track": "En Camino"

View File

@@ -95,6 +95,24 @@
"subtitle": "Monitorear métricas y tendencias de calidad",
"error": "Error al cargar datos de calidad"
},
"categories": {
"weight_check": "Control de Peso",
"temperature_check": "Control de Temperatura",
"moisture_check": "Control de Humedad",
"volume_check": "Control de Volumen",
"appearance": "Apariencia",
"structure": "Estructura",
"texture": "Textura",
"flavor": "Sabor",
"safety": "Seguridad",
"packaging": "Empaque",
"temperature": "Temperatura",
"weight": "Peso",
"dimensions": "Dimensiones",
"time_check": "Control de Tiempo",
"chemical": "Químico",
"hygiene": "Higiene"
},
"inspection": {
"title": "Inspección de Calidad",
"notes_placeholder": "Agregar notas para este criterio (opcional)..."

View File

@@ -1,7 +1,85 @@
{
"bakery": {
"title": "Ajustes de la Panadería",
"description": "Configura la información y ajustes operativos de tu panadería",
"tabs": {
"information": "Datos del establecimiento",
"hours": "Horarios",
"operations": "Ajustes operacionales"
},
"information": {
"title": "Información General",
"description": "Datos básicos y preferencias de tu panadería",
"general_section": "Información General",
"location_section": "Ubicación",
"business_section": "Datos de Empresa",
"fields": {
"name": "Nombre de la Panadería",
"description": "Descripción",
"email": "Email de Contacto",
"phone": "Teléfono",
"website": "Sitio Web",
"address": "Dirección",
"city": "Ciudad",
"postal_code": "Código Postal",
"country": "País",
"tax_id": "NIF/CIF",
"currency": "Moneda",
"timezone": "Zona Horaria",
"language": "Idioma"
},
"placeholders": {
"name": "Nombre de tu panadería",
"email": "contacto@panaderia.com",
"phone": "+34 912 345 678",
"website": "https://tu-panaderia.com",
"address": "Calle, número, etc.",
"city": "Ciudad",
"postal_code": "28001",
"country": "España",
"tax_id": "B12345678",
"description": "Describe tu panadería..."
}
},
"hours": {
"title": "Horarios de Apertura",
"description": "Configura los horarios de tu panadería",
"days": {
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado",
"sunday": "Domingo"
},
"closed": "Cerrado",
"closed_all_day": "Cerrado todo el día",
"open_time": "Apertura",
"close_time": "Cierre"
},
"operations": {
"title": "Ajustes Operacionales",
"description": "Configura los parámetros operativos de tu panadería",
"procurement": "Gestión de Compras",
"inventory": "Gestión de Inventario",
"production": "Gestión de Producción",
"suppliers": "Gestión de Proveedores",
"pos": "Punto de Venta",
"orders": "Gestión de Pedidos"
},
"unsaved_changes": "Tienes cambios sin guardar",
"save_success": "Información actualizada correctamente",
"save_error": "Error al actualizar"
},
"profile": {
"title": "Perfil de Usuario",
"title": "Ajustes",
"description": "Gestiona tu información personal y preferencias",
"tabs": {
"personal": "Información",
"notifications": "Notificaciones",
"privacy": "Privacidad"
},
"personal_info": "Información Personal",
"edit_profile": "Editar Perfil",
"change_password": "Cambiar Contraseña",
@@ -19,11 +97,55 @@
"avatar": "Avatar"
},
"password": {
"title": "Cambiar Contraseña",
"current_password": "Contraseña Actual",
"new_password": "Nueva Contraseña",
"confirm_password": "Confirmar Contraseña",
"change_password": "Cambiar Contraseña",
"password_requirements": "La contraseña debe tener al menos 8 caracteres"
"password_requirements": "La contraseña debe tener al menos 8 caracteres",
"change_success": "Contraseña actualizada correctamente",
"change_error": "No se pudo cambiar tu contraseña"
},
"notifications": {
"title": "Preferencias de Notificación",
"description": "Configura cómo y cuándo recibes notificaciones",
"contact_info": "Información de Contacto",
"global_settings": "Configuración General",
"channel_controls": "Control de Canales",
"categories": {
"alerts": "Alertas",
"reports": "Reportes",
"marketing": "Marketing"
},
"channels": {
"email": "Email",
"push": "Push",
"whatsapp": "WhatsApp"
},
"quiet_hours": "Horas Silenciosas",
"quiet_hours_description": "Sin notificaciones durante este periodo",
"digest_frequency": "Frecuencia de Resumen",
"email_limit": "Límite diario",
"preferences_saved": "Preferencias guardadas correctamente",
"preferences_error": "Error al guardar las preferencias"
},
"privacy": {
"title": "Privacidad y Datos",
"description": "Gestiona tu privacidad y datos personales",
"gdpr_rights": "Tus Derechos de Datos",
"gdpr_description": "Bajo el GDPR, tienes derecho a acceder, exportar y eliminar tus datos personales",
"export_data": "Exportar Tus Datos",
"export_description": "Descarga una copia de todos tus datos personales en formato JSON",
"export_button": "Exportar Mis Datos",
"export_success": "Tus datos han sido exportados exitosamente",
"export_error": "Error al exportar tus datos. Por favor, inténtalo de nuevo",
"delete_account": "Eliminar Cuenta",
"delete_description": "Eliminar permanentemente tu cuenta y todos los datos asociados",
"delete_button": "Eliminar Mi Cuenta",
"delete_warning": "Esta acción no se puede deshacer",
"cookie_preferences": "Preferencias de Cookies",
"privacy_policy": "Política de Privacidad",
"terms": "Términos de Servicio"
}
},
"team": {
@@ -43,14 +165,6 @@
"switch_organization": "Cambiar Organización",
"create_organization": "Crear Organización"
},
"bakery_config": {
"title": "Configuración de Panadería",
"description": "Configura los ajustes específicos de tu panadería",
"general": "General",
"products": "Productos",
"hours": "Horarios",
"notifications": "Notificaciones"
},
"subscription": {
"title": "Suscripción",
"description": "Gestiona tu plan de suscripción",
@@ -60,32 +174,19 @@
"upgrade": "Actualizar Plan",
"manage": "Gestionar Suscripción"
},
"communication": {
"title": "Preferencias de Comunicación",
"description": "Configura cómo y cuándo recibes notificaciones",
"email_notifications": "Notificaciones por Email",
"push_notifications": "Notificaciones Push",
"sms_notifications": "Notificaciones SMS",
"marketing": "Comunicaciones de Marketing",
"alerts": "Alertas del Sistema"
},
"tabs": {
"profile": "Perfil",
"team": "Equipo",
"organization": "Organización",
"bakery_config": "Configuración",
"subscription": "Suscripción",
"communication": "Comunicación"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"discard": "Descartar",
"edit": "Editar",
"delete": "Eliminar",
"loading": "Cargando...",
"saving": "Guardando...",
"success": "Éxito",
"error": "Error",
"required": "Requerido",
"optional": "Opcional"
"optional": "Opcional",
"reset": "Restablecer",
"reset_all": "Restablecer Todo"
}
}

View File

@@ -73,7 +73,27 @@
"delivery_status": "Estado de Entrega",
"quality_rating": "Calificación de Calidad",
"delivery_rating": "Calificación de Entrega",
"invoice_status": "Estado de Factura"
"invoice_status": "Estado de Factura",
"supplier_code": "Código de Proveedor",
"lead_time": "Tiempo de Entrega (días)",
"minimum_order": "Pedido Mínimo",
"credit_limit": "Límite de Crédito",
"currency": "Moneda",
"created_date": "Fecha de Creación",
"updated_date": "Última Actualización",
"notes": "Observaciones"
},
"sections": {
"contact_info": "Información de Contacto",
"commercial_info": "Información Comercial",
"performance": "Rendimiento y Estadísticas",
"notes": "Notas"
},
"placeholders": {
"name": "Nombre del proveedor",
"contact_person": "Nombre del contacto",
"supplier_code": "Código único",
"notes": "Notas sobre el proveedor"
},
"descriptions": {
"supplier_type": "Selecciona el tipo de productos o servicios que ofrece este proveedor",
@@ -81,4 +101,4 @@
"quality_rating": "Calificación de 1 a 5 estrellas basada en la calidad de los productos",
"delivery_rating": "Calificación de 1 a 5 estrellas basada en la puntualidad y estado de las entregas"
}
}
}

View File

@@ -51,10 +51,40 @@
"title": "Elegibilidad para Subvenciones",
"overall_readiness": "Preparación General",
"programs": {
"eu_horizon_europe": "Horizonte Europa UE",
"eu_farm_to_fork": "De la Granja a la Mesa UE",
"national_circular_economy": "Subvenciones Economía Circular",
"un_sdg_certified": "Certificación ODS ONU"
"life_circular_economy": "Programa LIFE - Economía Circular",
"life_circular_economy_description": "Programa LIFE de la UE para iniciativas de economía circular y reducción de desperdicio alimentario",
"life_circular_economy_funding": "€73M disponibles para proyectos de economía circular",
"life_circular_economy_deadline": "Fecha límite: 23 de septiembre de 2025",
"life_circular_economy_requirement": "Requiere 15% de reducción de desperdicio desde línea base",
"life_circular_economy_link": "https://cinea.ec.europa.eu/life-calls-proposals-2025_en",
"horizon_europe_cluster_6": "Horizonte Europa - Cluster 6 Sistemas Alimentarios",
"horizon_europe_cluster_6_description": "Financiación I+D para sistemas alimentarios sostenibles, bioeconomía y reducción de desperdicio",
"horizon_europe_cluster_6_funding": "€880M+ anuales para proyectos de sistemas alimentarios",
"horizon_europe_cluster_6_deadline": "Convocatorias continuas durante 2025",
"horizon_europe_cluster_6_requirement": "Requiere 20% de reducción de desperdicio desde línea base",
"horizon_europe_cluster_6_link": "https://research-and-innovation.ec.europa.eu/funding/cluster-6",
"fedima_sustainability_grant": "Subvención de Sostenibilidad Fedima",
"fedima_sustainability_grant_description": "Subvención semestral para iniciativas locales de sostenibilidad en panaderías",
"fedima_sustainability_grant_funding": "€20.000 por proyecto",
"fedima_sustainability_grant_deadline": "Próxima fecha límite: 30 de junio de 2025",
"fedima_sustainability_grant_requirement": "Requiere 15% de reducción de desperdicio (específico para panaderías)",
"fedima_sustainability_grant_link": "https://grant.fedima.org",
"eit_food_retail": "EIT Food - Innovación en Retail",
"eit_food_retail_description": "Apoyo para lanzamientos de productos alimentarios en retail que aborden desafíos críticos",
"eit_food_retail_funding": "€15.000 - €45.000 por proyecto",
"eit_food_retail_deadline": "Solicitudes continuas",
"eit_food_retail_requirement": "Requiere 20% de reducción de desperdicio e innovación en retail",
"eit_food_retail_link": "https://www.eitfood.eu/funding",
"un_sdg_certified": "Certificación ODS 12.3 de la ONU",
"un_sdg_certified_description": "Certificación oficial por alcanzar los objetivos del ODS 12.3",
"un_sdg_certified_funding": "Certificación (no financiación)",
"un_sdg_certified_deadline": "Proceso de certificación continuo",
"un_sdg_certified_requirement": "Requiere 50% de reducción de desperdicio desde línea base",
"un_sdg_certified_link": "https://sdgs.un.org/goals/goal12"
},
"confidence": {
"high": "Alta Confianza",
@@ -65,6 +95,11 @@
"eligible": "Elegible",
"not_eligible": "No Elegible",
"requirements_met": "Requisitos Cumplidos"
},
"spain_compliance": {
"title": "Cumplimiento Normativo Español",
"law_1_2025": "Ley Española 1/2025 de Prevención del Desperdicio Alimentario",
"circular_economy_strategy": "Estrategia Española de Economía Circular 2030"
}
},
"waste": {
@@ -84,10 +119,11 @@
"export_error": "Error al exportar el informe",
"types": {
"general": "Informe General de Sostenibilidad",
"eu_horizon": "Formato Horizonte Europa",
"farm_to_fork": "Informe De la Granja a la Mesa",
"circular_economy": "Informe Economía Circular",
"un_sdg": "Informe Certificación ODS ONU"
"life_circular_economy": "Solicitud Programa LIFE - Economía Circular",
"horizon_europe_cluster_6": "Solicitud Horizonte Europa Cluster 6",
"fedima_sustainability_grant": "Solicitud Subvención Sostenibilidad Fedima",
"eit_food_retail": "Solicitud EIT Food Innovación en Retail",
"un_sdg": "Informe Certificación ODS 12.3 ONU"
}
}
}

View File

@@ -185,6 +185,60 @@
"november": "Azaroa",
"december": "Abendua"
},
"fields": {
"name": "Izena",
"contact_person": "Kontaktu pertsona",
"email": "Emaila",
"phone": "Telefonoa",
"city": "Hiria",
"country": "Herrialdea",
"address": "Helbidea",
"postal_code": "Posta kodea",
"region": "Eskualdea",
"state": "Egoera",
"company": "Enpresa",
"position": "Kargua",
"department": "Saila",
"title": "Izenburua",
"description": "Deskribapena",
"notes": "Oharrak",
"status": "Egoera",
"type": "Mota",
"category": "Kategoria",
"priority": "Lehentasuna",
"date": "Data",
"time": "Ordua",
"amount": "Zenbatekoa",
"quantity": "Kantitatea",
"price": "Prezioa",
"cost": "Kostua",
"total": "Guztira",
"discount": "Deskontua",
"tax": "Zerga",
"currency": "Txanpona",
"reference": "Erreferentzia",
"code": "Kodea",
"id": "ID",
"created_at": "Sortze data",
"updated_at": "Eguneratze data",
"due_date": "Epemuga",
"start_date": "Hasiera data",
"end_date": "Amaiera data",
"duration": "Iraupena",
"percentage": "Ehunekoa",
"rate": "Tasa",
"score": "Puntuazioa",
"rating": "Balorazioa",
"version": "Bertsioa",
"version_number": "Bertsio zenbakia",
"version_date": "Bertsio data",
"version_notes": "Bertsio oharrak",
"version_status": "Bertsio egoera",
"version_type": "Bertsio mota",
"version_category": "Bertsio kategoria",
"version_priority": "Bertsio lehentasuna",
"version_description": "Bertsio deskribapena"
},
"forms": {
"required": "Beharrezkoa",
"optional": "Aukerakoa",
@@ -356,4 +410,4 @@
"language": "Hizkuntza",
"open_menu": "Ireki nabigazio menua"
}
}
}

View File

@@ -288,13 +288,19 @@
}
},
"grants": {
"eu_horizon": "EBko Horizonte Europa",
"eu_horizon_req": "%30eko murrizketa behar du",
"farm_to_fork": "Baratzatik Mahairako",
"farm_to_fork_req": "%20ko murrizketa behar du",
"circular_economy": "Ekonomia Zirkularra",
"circular_economy_req": "%15eko murrizketa behar du",
"un_sdg": "NBEren GIH Ziurtagiria",
"life_circular_economy": "LIFE Programa - Ekonomia Zirkularra",
"life_circular_economy_req": "%15eko murrizketa behar du",
"life_circular_economy_funding": "€73M eskuragarri",
"horizon_europe_cluster_6": "Horizonte Europa 6. multzoa",
"horizon_europe_cluster_6_req": "%20ko murrizketa behar du",
"horizon_europe_cluster_6_funding": "€880M+ urtero",
"fedima_sustainability_grant": "Fedima Iraunkortasun Diru-laguntza",
"fedima_sustainability_grant_req": "%15eko murrizketa behar du",
"fedima_sustainability_grant_funding": "€20.000 proiektuko",
"eit_food_retail": "EIT Food - Salmenta Berrikuntza",
"eit_food_retail_req": "%20ko murrizketa behar du",
"eit_food_retail_funding": "€15-45k proiektuko",
"un_sdg": "NBEren GIH 12.3 Ziurtagiria",
"un_sdg_req": "%50eko murrizketa behar du",
"eligible": "Kualifikatua",
"on_track": "Bidean"

View File

@@ -80,5 +80,26 @@
"equipment": {
"title": "Makinen egoera",
"subtitle": "Monitorizatu makinen osasuna eta errendimendua"
},
"quality": {
"title": "Kalitate Kontrola",
"categories": {
"weight_check": "Pisu Kontrola",
"temperature_check": "Tenperatura Kontrola",
"moisture_check": "Hezetasun Kontrola",
"volume_check": "Bolumen Kontrola",
"appearance": "Itxura",
"structure": "Egitura",
"texture": "Testura",
"flavor": "Zaporea",
"safety": "Segurtasuna",
"packaging": "Ontziratzea",
"temperature": "Tenperatura",
"weight": "Pisua",
"dimensions": "Dimentsioak",
"time_check": "Denbora Kontrola",
"chemical": "Kimikoa",
"hygiene": "Higienea"
}
}
}

View File

@@ -1,7 +1,85 @@
{
"bakery": {
"title": "Okindegi Ezarpenak",
"description": "Konfiguratu zure okindegiko informazioa eta ezarpen operatiboak",
"tabs": {
"information": "Informazioa",
"hours": "Ordutegiak",
"operations": "Ezarpenak"
},
"information": {
"title": "Informazio Orokorra",
"description": "Zure okindegiko oinarrizko datuak eta lehentasunak",
"general_section": "Informazio Orokorra",
"location_section": "Kokapena",
"business_section": "Enpresa Datuak",
"fields": {
"name": "Okindegi Izena",
"description": "Deskribapena",
"email": "Harremaneko Email",
"phone": "Telefonoa",
"website": "Webgunea",
"address": "Helbidea",
"city": "Hiria",
"postal_code": "Posta Kodea",
"country": "Herrialdea",
"tax_id": "IFK",
"currency": "Moneta",
"timezone": "Ordu Zona",
"language": "Hizkuntza"
},
"placeholders": {
"name": "Zure okindegi izena",
"email": "harremanak@okindeg ia.com",
"phone": "+34 943 123 456",
"website": "https://zure-okindegia.com",
"address": "Kalea, zenbakia, etab.",
"city": "Hiria",
"postal_code": "20001",
"country": "Espainia",
"tax_id": "B12345678",
"description": "Deskribatu zure okindegia..."
}
},
"hours": {
"title": "Irekiera Ordutegiak",
"description": "Konfiguratu zure okindegiko ordutegiak",
"days": {
"monday": "Astelehena",
"tuesday": "Asteartea",
"wednesday": "Asteazkena",
"thursday": "Osteguna",
"friday": "Ostirala",
"saturday": "Larunbata",
"sunday": "Igandea"
},
"closed": "Itxita",
"closed_all_day": "Egun osoan itxita",
"open_time": "Irekiera",
"close_time": "Itxiera"
},
"operations": {
"title": "Ezarpen Operatiboak",
"description": "Konfiguratu zure okindegiko parametro operatiboak",
"procurement": "Erosketen Kudeaketa",
"inventory": "Inbentarioaren Kudeaketa",
"production": "Ekoizpenaren Kudeaketa",
"suppliers": "Hornitzaileen Kudeaketa",
"pos": "Salmenta Puntua",
"orders": "Eskaeren Kudeaketa"
},
"unsaved_changes": "Gorde gabeko aldaketak dituzu",
"save_success": "Informazioa eguneratu da",
"save_error": "Errorea eguneratzean"
},
"profile": {
"title": "Erabiltzaile Profila",
"title": "Ezarpenak",
"description": "Kudeatu zure informazio pertsonala eta lehentasunak",
"tabs": {
"personal": "Informazioa",
"notifications": "Jakinarazpenak",
"privacy": "Pribatutasuna"
},
"personal_info": "Informazio Pertsonala",
"edit_profile": "Profila Editatu",
"change_password": "Pasahitza Aldatu",
@@ -19,11 +97,55 @@
"avatar": "Avatarra"
},
"password": {
"title": "Pasahitza Aldatu",
"current_password": "Oraingo Pasahitza",
"new_password": "Pasahitz Berria",
"confirm_password": "Pasahitza Berretsi",
"change_password": "Pasahitza Aldatu",
"password_requirements": "Pasahitzak gutxienez 8 karaktere izan behar ditu"
"password_requirements": "Pasahitzak gutxienez 8 karaktere izan behar ditu",
"change_success": "Pasahitza eguneratu da",
"change_error": "Ezin izan da pasahitza aldatu"
},
"notifications": {
"title": "Jakinarazpen Lehentasunak",
"description": "Konfiguratu nola eta noiz jasotzen dituzun jakinarazpenak",
"contact_info": "Harreman Informazioa",
"global_settings": "Ezarpen Orokorrak",
"channel_controls": "Kanal Kontrolak",
"categories": {
"alerts": "Alertak",
"reports": "Txostenak",
"marketing": "Marketing"
},
"channels": {
"email": "Email",
"push": "Push",
"whatsapp": "WhatsApp"
},
"quiet_hours": "Ordu Isilak",
"quiet_hours_description": "Ez dago jakinarazpenik aldialdi honetan",
"digest_frequency": "Laburpen Maiztasuna",
"email_limit": "Eguneko muga",
"preferences_saved": "Lehentasunak gorde dira",
"preferences_error": "Errorea lehentasunak gordetzean"
},
"privacy": {
"title": "Pribatutasuna eta Datuak",
"description": "Kudeatu zure pribatutasuna eta datu pertsonalak",
"gdpr_rights": "Zure Datu Eskubideak",
"gdpr_description": "GDPR-ren arabera, zure datu pertsonalak atzitu, esportatu eta ezabatzeko eskubidea duzu",
"export_data": "Esportatu Zure Datuak",
"export_description": "Deskargatu zure datu pertsonal guztien kopia JSON formatuan",
"export_button": "Nire Datuak Esportatu",
"export_success": "Zure datuak esportatu dira",
"export_error": "Errorea datuak esportatzean. Saiatu berriro mesedez",
"delete_account": "Kontua Ezabatu",
"delete_description": "Ezabatu betiko zure kontua eta datu guztiak",
"delete_button": "Nire Kontua Ezabatu",
"delete_warning": "Ekintza hau ezin da desegin",
"cookie_preferences": "Cookie Lehentasunak",
"privacy_policy": "Pribatutasun Politika",
"terms": "Zerbitzu Baldintzak"
}
},
"team": {
@@ -43,14 +165,6 @@
"switch_organization": "Erakundea Aldatu",
"create_organization": "Erakundea Sortu"
},
"bakery_config": {
"title": "Okindegi Konfigurazioa",
"description": "Konfiguratu zure okindegiko ezarpen bereziak",
"general": "Orokorra",
"products": "Produktuak",
"hours": "Ordu Koadroa",
"notifications": "Jakinarazpenak"
},
"subscription": {
"title": "Harpidetza",
"description": "Kudeatu zure harpidetza plana",
@@ -60,32 +174,19 @@
"upgrade": "Plana Eguneratu",
"manage": "Harpidetza Kudeatu"
},
"communication": {
"title": "Komunikazio Lehentasunak",
"description": "Konfiguratu nola eta noiz jasotzen dituzun jakinarazpenak",
"email_notifications": "Email Jakinarazpenak",
"push_notifications": "Push Jakinarazpenak",
"sms_notifications": "SMS Jakinarazpenak",
"marketing": "Marketing Komunikazioak",
"alerts": "Sistemaren Alertak"
},
"tabs": {
"profile": "Profila",
"team": "Taldea",
"organization": "Erakundea",
"bakery_config": "Konfigurazioa",
"subscription": "Harpidetza",
"communication": "Komunikazioa"
},
"common": {
"save": "Gorde",
"cancel": "Utzi",
"discard": "Baztertu",
"edit": "Editatu",
"delete": "Ezabatu",
"loading": "Kargatzen...",
"saving": "Gordetzen...",
"success": "Arrakasta",
"error": "Errorea",
"required": "Beharrezkoa",
"optional": "Aukerakoa"
"optional": "Aukerakoa",
"reset": "Berrezarri",
"reset_all": "Dena Berrezarri"
}
}

View File

@@ -1 +1,104 @@
{}
{
"types": {
"ingredients": "Osagaiak",
"packaging": "Paketea",
"equipment": "Tresnak",
"services": "Zerbitzuak",
"utilities": "Erabilgarriak",
"multi": "Hainbat Kategoria"
},
"status": {
"active": "Aktiboa",
"inactive": "Inaktiboa",
"pending_approval": "Onarpenaren zain",
"suspended": "Kanporatuta",
"blacklisted": "Zerrenda beltzean"
},
"payment_terms": {
"cod": "Entregan ordaindu",
"net_15": "15 eguneko sarea",
"net_30": "30 eguneko sarea",
"net_45": "45 eguneko sarea",
"net_60": "60 eguneko sarea",
"prepaid": "Aurrez ordainduta",
"credit_terms": "Kreditu baldintzak"
},
"purchase_order_status": {
"draft": "Zirriborroa",
"pending_approval": "Onarpenaren zain",
"approved": "Onartua",
"sent_to_supplier": "Hornitzaileari bidalia",
"confirmed": "Berretsia",
"partially_received": "Partzialki jasoa",
"completed": "Osatua",
"cancelled": "Ezeztatua",
"disputed": "Ukana"
},
"delivery_status": {
"scheduled": "Programatuta",
"in_transit": "Bidaian",
"out_for_delivery": "Entregarako kanpoan",
"delivered": "Entregatua",
"partially_delivered": "Partzialki entregatua",
"failed_delivery": "Entrega hutsa",
"returned": "Itzulita"
},
"quality_rating": {
"5": "Bikaina",
"4": "Ona",
"3": "Batez bestekoa",
"2": "Txarra",
"1": "Oso txarra"
},
"delivery_rating": {
"5": "Bikaina",
"4": "Ona",
"3": "Batez bestekoa",
"2": "Txarra",
"1": "Oso txarra"
},
"invoice_status": {
"pending": "Zain",
"approved": "Onartua",
"paid": "Ordainduta",
"overdue": "Atzeratua",
"disputed": "Ukana",
"cancelled": "Ezeztatua"
},
"labels": {
"supplier_type": "Hornitzaile mota",
"supplier_status": "Hornitzailearen egoera",
"payment_terms": "Ordainketa baldintzak",
"purchase_order_status": "Erosketa aginduaren egoera",
"delivery_status": "Entregaren egoera",
"quality_rating": "Kalitatearen balorazioa",
"delivery_rating": "Entregaren balorazioa",
"invoice_status": "Fakturaren egoera",
"supplier_code": "Hornitzailearen kodea",
"lead_time": "Entrega denbora (egunak)",
"minimum_order": "Gutxieneko eskaera",
"credit_limit": "Kreditu muga",
"currency": "Moneta",
"created_date": "Sortze data",
"updated_date": "Azken eguneraketa",
"notes": "Oharrak"
},
"sections": {
"contact_info": "Kontaktu informazioa",
"commercial_info": "Informazio komertziala",
"performance": "Errendimendua eta estatistikak",
"notes": "Oharrak"
},
"placeholders": {
"name": "Hornitzailearen izena",
"contact_person": "Kontaktuaren izena",
"supplier_code": "Kode esklusiboa",
"notes": "Oharrak hornitzaileari buruz"
},
"descriptions": {
"supplier_type": "Hautatu hornitzaile honek ematen dituen produktuen edo zerbitzuen mota",
"payment_terms": "Hornitzailearekin hitz egindako ordainketa baldintzak",
"quality_rating": "1etik 5erako izarra balorazioa produktuaren kalitatean oinarrituta",
"delivery_rating": "1etik 5erako izarra balorazioa entrega puntualtasunean eta baldintzetan oinarrituta"
}
}

View File

@@ -51,10 +51,40 @@
"title": "Diru-laguntzetarako Gaitasuna",
"overall_readiness": "Prestutasun Orokorra",
"programs": {
"eu_horizon_europe": "EB Horizonte Europa",
"eu_farm_to_fork": "EB Baratzatik Mahairako",
"national_circular_economy": "Ekonomia Zirkularreko Diru-laguntzak",
"un_sdg_certified": "NBE GIH Ziurtagiria"
"life_circular_economy": "LIFE Programa - Ekonomia Zirkularra",
"life_circular_economy_description": "EBren LIFE Programa ekonomia zirkularreko ekimenetarako eta elikagai-hondakinen murrizketarako",
"life_circular_economy_funding": "€73M eskuragarri ekonomia zirkularreko proiektuetarako",
"life_circular_economy_deadline": "Azken eguna: 2025eko irailaren 23a",
"life_circular_economy_requirement": "%15eko hondakinen murrizketa behar da oinarri-lerrotik",
"life_circular_economy_link": "https://cinea.ec.europa.eu/life-calls-proposals-2025_en",
"horizon_europe_cluster_6": "Horizonte Europa - 6. multzoa Elikagai Sistemak",
"horizon_europe_cluster_6_description": "I+G finantzaketa elikagai sistema jasangarrietarako, bioekonomiako eta hondakinen murrizketarako",
"horizon_europe_cluster_6_funding": "€880M+ urtero elikagai sistemen proiektuetarako",
"horizon_europe_cluster_6_deadline": "Deialdi jarraitua 2025ean",
"horizon_europe_cluster_6_requirement": "%20ko hondakinen murrizketa behar da oinarri-lerrotik",
"horizon_europe_cluster_6_link": "https://research-and-innovation.ec.europa.eu/funding/cluster-6",
"fedima_sustainability_grant": "Fedimaren Iraunkortasun Diru-laguntza",
"fedima_sustainability_grant_description": "Sei hilabeteko diru-laguntza okindegietako iraunkortasun ekimen lokaletarako",
"fedima_sustainability_grant_funding": "€20.000 proiektu bakoitzeko",
"fedima_sustainability_grant_deadline": "Hurrengo azken eguna: 2025eko ekainaren 30a",
"fedima_sustainability_grant_requirement": "%15eko hondakinen murrizketa behar da (okindegia-espezifikoa)",
"fedima_sustainability_grant_link": "https://grant.fedima.org",
"eit_food_retail": "EIT Food - Salmenta Berrikuntza",
"eit_food_retail_description": "Laguntza elikagai-produktuen salmentetarako erronka kritikoak gainditzeko",
"eit_food_retail_funding": "€15.000 - €45.000 proiektu bakoitzeko",
"eit_food_retail_deadline": "Eskaera jarraitua",
"eit_food_retail_requirement": "%20ko hondakinen murrizketa eta salmentako berrikuntza behar da",
"eit_food_retail_link": "https://www.eitfood.eu/funding",
"un_sdg_certified": "NBEren GIH 12.3 Ziurtagiria",
"un_sdg_certified_description": "Ziurtagiri ofiziala GIH 12.3 helburuak lortzeagatik",
"un_sdg_certified_funding": "Ziurtagiria (ez finantzaketa)",
"un_sdg_certified_deadline": "Ziurtagiri-prozesua etengabea",
"un_sdg_certified_requirement": "%50eko hondakinen murrizketa behar da oinarri-lerrotik",
"un_sdg_certified_link": "https://sdgs.un.org/goals/goal12"
},
"confidence": {
"high": "Konfiantza Handia",
@@ -65,6 +95,11 @@
"eligible": "Eskuragarri",
"not_eligible": "Ez Dago Eskuragarri",
"requirements_met": "Eskakizunak Betetzen"
},
"spain_compliance": {
"title": "Espainiako Arauak Betetzen",
"law_1_2025": "Espainiako 1/2025 Legea Elikagai-hondakinen Prebentzioa",
"circular_economy_strategy": "Espainiako Ekonomia Zirkularraren Estrategia 2030"
}
},
"waste": {
@@ -84,10 +119,11 @@
"export_error": "Errorea txostena esportatzean",
"types": {
"general": "Iraunkortasun Txosten Orokorra",
"eu_horizon": "Horizonte Europa Formatua",
"farm_to_fork": "Baratzatik Mahairako Txostena",
"circular_economy": "Ekonomia Zirkularreko Txostena",
"un_sdg": "NBE GIH Ziurtagiri Txostena"
"life_circular_economy": "LIFE Programa Eskaera - Ekonomia Zirkularra",
"horizon_europe_cluster_6": "Horizonte Europa 6. multzoa Eskaera",
"fedima_sustainability_grant": "Fedima Iraunkortasun Diru-laguntza Eskaera",
"eit_food_retail": "EIT Food Salmenta Berrikuntza Eskaera",
"un_sdg": "NBE GIH 12.3 Ziurtagiri Txostena"
}
}
}

View File

@@ -11,6 +11,7 @@ import dashboardEs from './es/dashboard.json';
import productionEs from './es/production.json';
import equipmentEs from './es/equipment.json';
import landingEs from './es/landing.json';
import settingsEs from './es/settings.json';
// English translations
import commonEn from './en/common.json';
@@ -25,6 +26,7 @@ import dashboardEn from './en/dashboard.json';
import productionEn from './en/production.json';
import equipmentEn from './en/equipment.json';
import landingEn from './en/landing.json';
import settingsEn from './en/settings.json';
// Basque translations
import commonEu from './eu/common.json';
@@ -39,6 +41,7 @@ import dashboardEu from './eu/dashboard.json';
import productionEu from './eu/production.json';
import equipmentEu from './eu/equipment.json';
import landingEu from './eu/landing.json';
import settingsEu from './eu/settings.json';
// Translation resources by language
export const resources = {
@@ -55,6 +58,7 @@ export const resources = {
production: productionEs,
equipment: equipmentEs,
landing: landingEs,
settings: settingsEs,
},
en: {
common: commonEn,
@@ -69,6 +73,7 @@ export const resources = {
production: productionEn,
equipment: equipmentEn,
landing: landingEn,
settings: settingsEn,
},
eu: {
common: commonEu,
@@ -83,6 +88,7 @@ export const resources = {
production: productionEu,
equipment: equipmentEu,
landing: landingEu,
settings: settingsEu,
},
};
@@ -119,7 +125,7 @@ export const languageConfig = {
};
// Namespaces available in translations
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing'] as const;
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings'] as const;
export type Namespace = typeof namespaces[number];
// Helper function to get language display name
@@ -133,7 +139,7 @@ export const isSupportedLanguage = (language: string): language is SupportedLang
};
// Export individual language modules for direct imports
export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, ordersEs, recipesEs, errorsEs, equipmentEs, landingEs };
export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, ordersEs, recipesEs, errorsEs, equipmentEs, landingEs, settingsEs };
// Default export with all translations
export default resources;

View File

@@ -120,30 +120,30 @@ const ProcurementAnalyticsPage: React.FC = () => {
stats={[
{
label: 'Planes Activos',
value: dashboard?.stats?.total_plans || 0,
value: dashboard?.summary?.total_plans || 0,
icon: ShoppingCart,
formatter: formatters.number
},
{
label: 'Tasa de Cumplimiento',
value: dashboard?.stats?.avg_fulfillment_rate || 0,
value: dashboard?.performance_metrics?.average_fulfillment_rate || 0,
icon: Target,
formatter: formatters.percentage,
change: dashboard?.stats?.fulfillment_trend
change: dashboard?.performance_metrics?.fulfillment_trend
},
{
label: 'Entregas a Tiempo',
value: dashboard?.stats?.avg_on_time_delivery || 0,
value: dashboard?.performance_metrics?.average_on_time_delivery || 0,
icon: Calendar,
formatter: formatters.percentage,
change: dashboard?.stats?.on_time_trend
change: dashboard?.performance_metrics?.on_time_trend
},
{
label: 'Variación de Costos',
value: dashboard?.stats?.avg_cost_variance || 0,
value: dashboard?.performance_metrics?.cost_accuracy || 0,
icon: DollarSign,
formatter: formatters.percentage,
change: dashboard?.stats?.cost_variance_trend
change: dashboard?.performance_metrics?.cost_variance_trend
}
]}
loading={dashboardLoading}
@@ -176,7 +176,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-primary)]"
style={{ width: `${(status.count / dashboard.stats.total_plans) * 100}%` }}
style={{ width: `${(status.count / (dashboard?.summary?.total_plans || 1)) * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-[var(--text-primary)] w-8 text-right">
@@ -275,7 +275,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
<div className="p-6 text-center">
<Target className="mx-auto h-8 w-8 text-[var(--color-success)] mb-3" />
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
{formatters.percentage(dashboard?.stats?.avg_fulfillment_rate || 0)}
{formatters.percentage(dashboard?.performance_metrics?.average_fulfillment_rate || 0)}
</div>
<div className="text-sm text-[var(--text-secondary)]">Tasa de Cumplimiento</div>
</div>
@@ -285,7 +285,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
<div className="p-6 text-center">
<Calendar className="mx-auto h-8 w-8 text-[var(--color-info)] mb-3" />
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
{formatters.percentage(dashboard?.stats?.avg_on_time_delivery || 0)}
{formatters.percentage(dashboard?.performance_metrics?.average_on_time_delivery || 0)}
</div>
<div className="text-sm text-[var(--text-secondary)]">Entregas a Tiempo</div>
</div>
@@ -295,7 +295,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
<div className="p-6 text-center">
<Award className="mx-auto h-8 w-8 text-[var(--color-warning)] mb-3" />
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
{dashboard?.stats?.avg_quality_score?.toFixed(1) || '0.0'}
{dashboard?.performance_metrics?.supplier_performance?.toFixed(1) || '0.0'}
</div>
<div className="text-sm text-[var(--text-secondary)]">Puntuación de Calidad</div>
</div>
@@ -372,23 +372,23 @@ const ProcurementAnalyticsPage: React.FC = () => {
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">Costo Total Estimado</span>
<span className="text-2xl font-bold text-[var(--text-primary)]">
{formatters.currency(dashboard?.cost_analysis?.total_estimated || 0)}
{formatters.currency(dashboard?.summary?.total_estimated_cost || 0)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">Costo Total Aprobado</span>
<span className="text-2xl font-bold text-[var(--text-primary)]">
{formatters.currency(dashboard?.cost_analysis?.total_approved || 0)}
{formatters.currency(dashboard?.summary?.total_approved_cost || 0)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">Variación Promedio</span>
<span className={`text-2xl font-bold ${
(dashboard?.cost_analysis?.avg_variance || 0) > 0
(dashboard?.summary?.cost_variance || 0) > 0
? 'text-[var(--color-error)]'
: 'text-[var(--color-success)]'
}`}>
{formatters.percentage(Math.abs(dashboard?.cost_analysis?.avg_variance || 0))}
{formatters.currency(Math.abs(dashboard?.summary?.cost_variance || 0))}
</span>
</div>
</div>
@@ -408,7 +408,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-primary)]"
style={{ width: `${(category.amount / dashboard.cost_analysis.total_estimated) * 100}%` }}
style={{ width: `${(category.amount / (dashboard?.summary?.total_estimated_cost || 1)) * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-[var(--text-primary)] w-20 text-right">

View File

@@ -1,577 +0,0 @@
import React, { useState } from 'react';
import { Store, MapPin, Clock, Phone, Mail, Globe, Save, X, AlertCircle, Loader } from 'lucide-react';
import { Button, Card, Input, Select } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { useUpdateTenant } from '../../../../api/hooks/tenant';
import { useCurrentTenant, useTenantActions } from '../../../../stores/tenant.store';
interface BakeryConfig {
// General Info
name: string;
description: string;
email: string;
phone: string;
website: string;
// Location
address: string;
city: string;
postalCode: string;
country: string;
// Business
taxId: string;
currency: string;
timezone: string;
language: string;
}
interface BusinessHours {
[key: string]: {
open: string;
close: string;
closed: boolean;
};
}
const InformationPage: React.FC = () => {
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const { loadUserTenants, setCurrentTenant } = useTenantActions();
const tenantId = currentTenant?.id || '';
// Use the current tenant from the store instead of making additional API calls
// to avoid the 422 validation error on the tenant GET endpoint
const tenant = currentTenant;
const tenantLoading = !currentTenant;
const tenantError = null;
const updateTenantMutation = useUpdateTenant();
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [config, setConfig] = useState<BakeryConfig>({
name: '',
description: '',
email: '',
phone: '',
website: '',
address: '',
city: '',
postalCode: '',
country: '',
taxId: '',
currency: 'EUR',
timezone: 'Europe/Madrid',
language: 'es'
});
// Load user tenants on component mount to ensure fresh data
React.useEffect(() => {
loadUserTenants();
}, [loadUserTenants]);
// Update config when tenant data is loaded
React.useEffect(() => {
if (tenant) {
setConfig({
name: tenant.name || '',
description: tenant.description || '',
email: tenant.email || '',
phone: tenant.phone || '',
website: tenant.website || '',
address: tenant.address || '',
city: tenant.city || '',
postalCode: tenant.postal_code || '',
country: tenant.country || '',
taxId: '', // Not supported by backend yet
currency: 'EUR', // Default value
timezone: 'Europe/Madrid', // Default value
language: 'es' // Default value
});
setHasUnsavedChanges(false); // Reset unsaved changes when loading fresh data
}
}, [tenant]);
const [businessHours, setBusinessHours] = useState<BusinessHours>({
monday: { open: '07:00', close: '20:00', closed: false },
tuesday: { open: '07:00', close: '20:00', closed: false },
wednesday: { open: '07:00', close: '20:00', closed: false },
thursday: { open: '07:00', close: '20:00', closed: false },
friday: { open: '07:00', close: '20:00', closed: false },
saturday: { open: '08:00', close: '14:00', closed: false },
sunday: { open: '09:00', close: '13:00', closed: false }
});
const [errors, setErrors] = useState<Record<string, string>>({});
const daysOfWeek = [
{ key: 'monday', label: 'Lunes' },
{ key: 'tuesday', label: 'Martes' },
{ key: 'wednesday', label: 'Miércoles' },
{ key: 'thursday', label: 'Jueves' },
{ key: 'friday', label: 'Viernes' },
{ key: 'saturday', label: 'Sábado' },
{ key: 'sunday', label: 'Domingo' }
];
const currencyOptions = [
{ value: 'EUR', label: 'EUR (€)' },
{ value: 'USD', label: 'USD ($)' },
{ value: 'GBP', label: 'GBP (£)' }
];
const timezoneOptions = [
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
];
const languageOptions = [
{ value: 'es', label: 'Español' },
{ value: 'ca', label: 'Català' },
{ value: 'en', label: 'English' }
];
const validateConfig = (): boolean => {
const newErrors: Record<string, string> = {};
if (!config.name.trim()) {
newErrors.name = 'El nombre es requerido';
}
if (!config.email.trim()) {
newErrors.email = 'El email es requerido';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(config.email)) {
newErrors.email = 'Email inválido';
}
if (!config.address.trim()) {
newErrors.address = 'La dirección es requerida';
}
if (!config.city.trim()) {
newErrors.city = 'La ciudad es requerida';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSaveConfig = async () => {
if (!validateConfig() || !tenantId) return;
setIsLoading(true);
try {
const updateData = {
name: config.name,
description: config.description,
email: config.email,
phone: config.phone,
website: config.website,
address: config.address,
city: config.city,
postal_code: config.postalCode,
country: config.country
};
const updatedTenant = await updateTenantMutation.mutateAsync({
tenantId,
updateData
});
// Update the tenant store with the new data
if (updatedTenant) {
setCurrentTenant(updatedTenant);
// Force reload tenant list to ensure cache consistency
await loadUserTenants();
// Update localStorage to persist the changes
const tenantStorage = localStorage.getItem('tenant-storage');
if (tenantStorage) {
const parsedStorage = JSON.parse(tenantStorage);
if (parsedStorage.state && parsedStorage.state.currentTenant) {
parsedStorage.state.currentTenant = updatedTenant;
localStorage.setItem('tenant-storage', JSON.stringify(parsedStorage));
}
}
}
setHasUnsavedChanges(false);
addToast('Información actualizada correctamente', { type: 'success' });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
addToast(`Error al actualizar: ${errorMessage}`, { type: 'error' });
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: keyof BakeryConfig) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setConfig(prev => ({ ...prev, [field]: e.target.value }));
setHasUnsavedChanges(true);
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleSelectChange = (field: keyof BakeryConfig) => (value: string) => {
setConfig(prev => ({ ...prev, [field]: value }));
setHasUnsavedChanges(true);
};
const handleHoursChange = (day: string, field: 'open' | 'close' | 'closed', value: string | boolean) => {
setBusinessHours(prev => ({
...prev,
[day]: {
...prev[day],
[field]: value
}
}));
setHasUnsavedChanges(true);
};
if (tenantLoading || !currentTenant) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Información de la Panadería"
description="Configura los datos básicos y preferencias de tu panadería"
/>
<div className="flex items-center justify-center h-64">
<Loader className="w-8 h-8 animate-spin" />
<span className="ml-2">Cargando información...</span>
</div>
</div>
);
}
if (tenantError) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Información de la Panadería"
description="Error al cargar la información"
/>
<Card className="p-6">
<div className="text-red-600">
Error al cargar la información: Error desconocido
</div>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6">
<PageHeader
title="Información de la Panadería"
description="Configura los datos básicos y preferencias de tu panadería"
/>
{/* Bakery Header */}
<Card className="p-6">
<div className="flex items-center gap-6">
<div className="w-16 h-16 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-xl">
{config.name.charAt(0)}
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-text-primary mb-1">
{config.name}
</h1>
<p className="text-text-secondary">{config.email}</p>
<p className="text-text-tertiary text-sm">{config.address}, {config.city}</p>
</div>
<div className="flex gap-2">
{hasUnsavedChanges && (
<div className="flex items-center gap-2 text-sm text-yellow-600">
<AlertCircle className="w-4 h-4" />
Cambios sin guardar
</div>
)}
</div>
</div>
</Card>
{/* Information Sections */}
<div className="space-y-8">
{/* General Information */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-primary mb-6 flex items-center">
<Store className="w-5 h-5 mr-2" />
Información General
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<Input
label="Nombre de la Panadería"
value={config.name}
onChange={handleInputChange('name')}
error={errors.name}
disabled={isLoading}
placeholder="Nombre de tu panadería"
leftIcon={<Store className="w-4 h-4" />}
/>
<Input
type="email"
label="Email de Contacto"
value={config.email}
onChange={handleInputChange('email')}
error={errors.email}
disabled={isLoading}
placeholder="contacto@panaderia.com"
leftIcon={<Mail className="w-4 h-4" />}
/>
<Input
type="tel"
label="Teléfono"
value={config.phone}
onChange={handleInputChange('phone')}
error={errors.phone}
disabled={isLoading}
placeholder="+34 912 345 678"
leftIcon={<Phone className="w-4 h-4" />}
/>
<Input
label="Sitio Web"
value={config.website}
onChange={handleInputChange('website')}
disabled={isLoading}
placeholder="https://tu-panaderia.com"
leftIcon={<Globe className="w-4 h-4" />}
className="md:col-span-2 xl:col-span-3"
/>
</div>
<div className="mt-6">
<label className="block text-sm font-medium text-text-secondary mb-2">
Descripción
</label>
<textarea
value={config.description}
onChange={handleInputChange('description')}
disabled={isLoading}
rows={3}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg resize-none bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
placeholder="Describe tu panadería..."
/>
</div>
</Card>
{/* Location Information */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-primary mb-6 flex items-center">
<MapPin className="w-5 h-5 mr-2" />
Ubicación
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<Input
label="Dirección"
value={config.address}
onChange={handleInputChange('address')}
error={errors.address}
disabled={isLoading}
placeholder="Calle, número, etc."
leftIcon={<MapPin className="w-4 h-4" />}
className="md:col-span-2"
/>
<Input
label="Ciudad"
value={config.city}
onChange={handleInputChange('city')}
error={errors.city}
disabled={isLoading}
placeholder="Ciudad"
/>
<Input
label="Código Postal"
value={config.postalCode}
onChange={handleInputChange('postalCode')}
disabled={isLoading}
placeholder="28001"
/>
<Input
label="País"
value={config.country}
onChange={handleInputChange('country')}
disabled={isLoading}
placeholder="España"
/>
</div>
</Card>
{/* Business Information */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-primary mb-6">Datos de Empresa</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<Input
label="NIF/CIF"
value={config.taxId}
onChange={handleInputChange('taxId')}
disabled={isLoading}
placeholder="B12345678"
/>
<Select
label="Moneda"
options={currencyOptions}
value={config.currency}
onChange={(value) => handleSelectChange('currency')(value as string)}
disabled={isLoading}
/>
<Select
label="Zona Horaria"
options={timezoneOptions}
value={config.timezone}
onChange={(value) => handleSelectChange('timezone')(value as string)}
disabled={isLoading}
/>
<Select
label="Idioma"
options={languageOptions}
value={config.language}
onChange={(value) => handleSelectChange('language')(value as string)}
disabled={isLoading}
/>
</div>
</Card>
{/* Business Hours */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-text-primary mb-6 flex items-center">
<Clock className="w-5 h-5 mr-2" />
Horarios de Apertura
</h3>
<div className="space-y-4">
{daysOfWeek.map((day) => {
const hours = businessHours[day.key];
return (
<div key={day.key} className="grid grid-cols-12 items-center gap-4 p-4 border border-border-primary rounded-lg">
{/* Day Name */}
<div className="col-span-2">
<span className="text-sm font-medium text-text-secondary">{day.label}</span>
</div>
{/* Closed Checkbox */}
<div className="col-span-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={hours.closed}
onChange={(e) => handleHoursChange(day.key, 'closed', e.target.checked)}
disabled={isLoading}
className="rounded border-border-primary"
/>
<span className="text-sm text-text-secondary">Cerrado</span>
</label>
</div>
{/* Time Inputs */}
<div className="col-span-8 flex items-center gap-6">
{!hours.closed ? (
<>
<div className="flex-1">
<label className="block text-xs text-text-tertiary mb-1">Apertura</label>
<input
type="time"
value={hours.open}
onChange={(e) => handleHoursChange(day.key, 'open', e.target.value)}
disabled={isLoading}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
/>
</div>
<div className="flex-1">
<label className="block text-xs text-text-tertiary mb-1">Cierre</label>
<input
type="time"
value={hours.close}
onChange={(e) => handleHoursChange(day.key, 'close', e.target.value)}
disabled={isLoading}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
/>
</div>
</>
) : (
<div className="text-sm text-text-tertiary italic">
Cerrado todo el día
</div>
)}
</div>
</div>
);
})}
</div>
</Card>
</div>
{/* Floating Save Button */}
{hasUnsavedChanges && (
<div className="fixed bottom-6 right-6 z-50">
<Card className="p-4 shadow-lg">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-text-secondary">
<AlertCircle className="w-4 h-4 text-yellow-500" />
Tienes cambios sin guardar
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
// Reset to original values
if (tenant) {
setConfig({
name: tenant.name || '',
description: tenant.description || '',
email: tenant.email || '',
phone: tenant.phone || '',
website: tenant.website || '',
address: tenant.address || '',
city: tenant.city || '',
postalCode: tenant.postal_code || '',
country: tenant.country || '',
taxId: '',
currency: 'EUR',
timezone: 'Europe/Madrid',
language: 'es'
});
}
setHasUnsavedChanges(false);
}}
disabled={isLoading}
>
<X className="w-4 h-4" />
Descartar
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveConfig}
isLoading={isLoading}
loadingText="Guardando..."
>
<Save className="w-4 h-4" />
Guardar
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
);
};
export default InformationPage;

View File

@@ -61,12 +61,13 @@ const InventoryPage: React.FC = () => {
isLoading: analyticsLoading
} = useStockAnalytics(tenantId);
// TODO: Implement expired stock API endpoint
// Expiring stock data (already implemented via useExpiringStock hook)
// Uncomment below if you need to display expired stock separately:
// const {
// data: expiredStockData,
// isLoading: expiredStockLoading,
// error: expiredStockError
// } = useExpiredStock(tenantId);
// data: expiringStockData,
// isLoading: expiringStockLoading,
// error: expiringStockError
// } = useExpiringStock(tenantId, 7); // items expiring within 7 days
// Stock movements for history modal
const {

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle } from 'lucide-react';
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle, Play } from 'lucide-react';
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { statusColors } from '../../../../styles/colors';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
@@ -12,6 +12,7 @@ import {
useActiveBatches,
useCreateProductionBatch,
useUpdateBatchStatus,
useTriggerProductionScheduler,
productionService
} from '../../../../api';
import type {
@@ -25,6 +26,7 @@ import {
} from '../../../../api';
import { useTranslation } from 'react-i18next';
import { ProcessStage } from '../../../../api/types/qualityTemplates';
import toast from 'react-hot-toast';
const ProductionPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
@@ -56,6 +58,7 @@ const ProductionPage: React.FC = () => {
// Mutations
const createBatchMutation = useCreateProductionBatch();
const updateBatchStatusMutation = useUpdateBatchStatus();
const triggerSchedulerMutation = useTriggerProductionScheduler();
// Handlers
const handleCreateBatch = async (batchData: ProductionBatchCreate) => {
@@ -70,6 +73,16 @@ const ProductionPage: React.FC = () => {
}
};
const handleTriggerScheduler = async () => {
try {
await triggerSchedulerMutation.mutateAsync(tenantId);
toast.success('Scheduler ejecutado exitosamente');
} catch (error) {
console.error('Error triggering scheduler:', error);
toast.error('Error al ejecutar scheduler');
}
};
// Stage management handlers
const handleStageAdvance = async (batchId: string, currentStage: ProcessStage) => {
const stages = Object.values(ProcessStage);
@@ -283,21 +296,30 @@ const ProductionPage: React.FC = () => {
return (
<div className="space-y-6">
<div className="flex items-center justify-between mb-6">
<PageHeader
title="Gestión de Producción"
description="Planifica y controla la producción diaria de tu panadería"
/>
<Button
variant="primary"
size="md"
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2.5 px-6 py-3 text-sm font-semibold tracking-wide shadow-lg hover:shadow-xl transition-all duration-200"
>
<PlusCircle className="w-5 h-5" />
Nueva Orden de Producción
</Button>
</div>
<PageHeader
title="Gestión de Producción"
description="Planifica y controla la producción diaria de tu panadería"
actions={[
{
id: 'trigger-scheduler',
label: triggerSchedulerMutation.isPending ? 'Ejecutando...' : 'Ejecutar Scheduler',
icon: Play,
onClick: handleTriggerScheduler,
variant: 'outline',
size: 'sm',
disabled: triggerSchedulerMutation.isPending,
loading: triggerSchedulerMutation.isPending
},
{
id: 'create-batch',
label: 'Nueva Orden de Producción',
icon: PlusCircle,
onClick: () => setShowCreateModal(true),
variant: 'primary',
size: 'md'
}
]}
/>
{/* Production Stats */}
<StatsGrid

View File

@@ -317,48 +317,48 @@ const SuppliersPage: React.FC = () => {
icon: Users,
fields: [
{
label: 'Nombre',
label: t('common:fields.name'),
value: selectedSupplier.name || '',
type: 'text',
type: 'text' as const,
highlight: true,
editable: true,
required: true,
placeholder: 'Nombre del proveedor'
placeholder: t('suppliers:placeholders.name')
},
{
label: 'Persona de Contacto',
label: t('common:fields.contact_person'),
value: selectedSupplier.contact_person || '',
type: 'text',
type: 'text' as const,
editable: true,
placeholder: 'Nombre del contacto'
placeholder: t('suppliers:placeholders.contact_person')
},
{
label: 'Email',
label: t('common:fields.email'),
value: selectedSupplier.email || '',
type: 'email',
type: 'email' as const,
editable: true,
placeholder: 'email@ejemplo.com'
placeholder: t('common:fields.email_placeholder')
},
{
label: 'Teléfono',
label: t('common:fields.phone'),
value: selectedSupplier.phone || '',
type: 'tel',
type: 'tel' as const,
editable: true,
placeholder: '+34 123 456 789'
placeholder: t('common:fields.phone_placeholder')
},
{
label: 'Ciudad',
label: t('common:fields.city'),
value: selectedSupplier.city || '',
type: 'text',
type: 'text' as const,
editable: true,
placeholder: 'Ciudad'
placeholder: t('common:fields.city')
},
{
label: 'País',
label: t('common:fields.country'),
value: selectedSupplier.country || '',
type: 'text',
type: 'text' as const,
editable: true,
placeholder: 'País'
placeholder: t('common:fields.country')
}
]
},
@@ -367,90 +367,94 @@ const SuppliersPage: React.FC = () => {
icon: Building2,
fields: [
{
label: 'Código de Proveedor',
label: t('suppliers:labels.supplier_code'),
value: selectedSupplier.supplier_code || '',
type: 'text',
type: 'text' as const,
highlight: true,
editable: true,
placeholder: 'Código único'
placeholder: t('suppliers:placeholders.supplier_code')
},
{
label: 'Tipo de Proveedor',
value: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
type: 'select',
label: t('suppliers:labels.supplier_type'),
value: modalMode === 'view'
? getSupplierTypeText(selectedSupplier.supplier_type || SupplierType.INGREDIENTS)
: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
type: modalMode === 'view' ? 'text' as const : 'select' as const,
editable: true,
options: Object.values(SupplierType).map(value => ({
options: modalMode === 'edit' ? Object.values(SupplierType).map(value => ({
value,
label: t(`suppliers:types.${value.toLowerCase()}`)
}))
})) : undefined
},
{
label: 'Condiciones de Pago',
value: selectedSupplier.payment_terms || PaymentTerms.NET_30,
type: 'select',
label: t('suppliers:labels.payment_terms'),
value: modalMode === 'view'
? getPaymentTermsText(selectedSupplier.payment_terms || PaymentTerms.NET_30)
: selectedSupplier.payment_terms || PaymentTerms.NET_30,
type: modalMode === 'view' ? 'text' as const : 'select' as const,
editable: true,
options: Object.values(PaymentTerms).map(value => ({
options: modalMode === 'edit' ? Object.values(PaymentTerms).map(value => ({
value,
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
}))
})) : undefined
},
{
label: 'Tiempo de Entrega (días)',
label: t('suppliers:labels.lead_time'),
value: selectedSupplier.standard_lead_time || 3,
type: 'number',
type: 'number' as const,
editable: true,
placeholder: '3'
},
{
label: 'Pedido Mínimo',
label: t('suppliers:labels.minimum_order'),
value: selectedSupplier.minimum_order_amount || 0,
type: 'currency',
type: 'currency' as const,
editable: true,
placeholder: '0.00'
},
{
label: 'Límite de Crédito',
label: t('suppliers:labels.credit_limit'),
value: selectedSupplier.credit_limit || 0,
type: 'currency',
type: 'currency' as const,
editable: true,
placeholder: '0.00'
}
]
},
{
title: 'Rendimiento y Estadísticas',
title: t('suppliers:sections.performance'),
icon: Euro,
fields: [
{
label: 'Moneda',
label: t('suppliers:labels.currency'),
value: selectedSupplier.currency || 'EUR',
type: 'text',
type: 'text' as const,
editable: true,
placeholder: 'EUR'
},
{
label: 'Fecha de Creación',
label: t('suppliers:labels.created_date'),
value: selectedSupplier.created_at,
type: 'datetime',
type: 'datetime' as const,
highlight: true
},
{
label: 'Última Actualización',
label: t('suppliers:labels.updated_date'),
value: selectedSupplier.updated_at,
type: 'datetime'
type: 'datetime' as const
}
]
},
...(selectedSupplier.notes ? [{
title: 'Notas',
title: t('suppliers:sections.notes'),
fields: [
{
label: 'Observaciones',
label: t('suppliers:labels.notes'),
value: selectedSupplier.notes,
type: 'list',
type: 'list' as const,
span: 2 as const,
editable: true,
placeholder: 'Notas sobre el proveedor'
placeholder: t('suppliers:placeholders.notes')
}
]
}] : [])
@@ -472,7 +476,7 @@ const SuppliersPage: React.FC = () => {
statusIndicator={isCreating ? undefined : getSupplierStatusConfig(selectedSupplier.status)}
size="lg"
sections={sections}
showDefaultActions={true}
showDefaultActions={modalMode === 'edit'}
onSave={async () => {
// TODO: Implement save functionality
console.log('Saving supplier:', selectedSupplier);
@@ -485,20 +489,20 @@ const SuppliersPage: React.FC = () => {
// Map field labels to supplier properties
const fieldMapping: { [key: string]: string } = {
'Nombre': 'name',
'Persona de Contacto': 'contact_person',
'Email': 'email',
'Teléfono': 'phone',
'Ciudad': 'city',
'País': 'country',
'Código de Proveedor': 'supplier_code',
'Tipo de Proveedor': 'supplier_type',
'Condiciones de Pago': 'payment_terms',
'Tiempo de Entrega (días)': 'standard_lead_time',
'Pedido Mínimo': 'minimum_order_amount',
'Límite de Crédito': 'credit_limit',
'Moneda': 'currency',
'Observaciones': 'notes'
[t('common:fields.name')]: 'name',
[t('common:fields.contact_person')]: 'contact_person',
[t('common:fields.email')]: 'email',
[t('common:fields.phone')]: 'phone',
[t('common:fields.city')]: 'city',
[t('common:fields.country')]: 'country',
[t('suppliers:labels.supplier_code')]: 'supplier_code',
[t('suppliers:labels.supplier_type')]: 'supplier_type',
[t('suppliers:labels.payment_terms')]: 'payment_terms',
[t('suppliers:labels.lead_time')]: 'standard_lead_time',
[t('suppliers:labels.minimum_order')]: 'minimum_order_amount',
[t('suppliers:labels.credit_limit')]: 'credit_limit',
[t('suppliers:labels.currency')]: 'currency',
[t('suppliers:labels.notes')]: 'notes'
};
const propertyName = fieldMapping[field.label];
@@ -514,4 +518,4 @@ const SuppliersPage: React.FC = () => {
);
};
export default SuppliersPage;
export default SuppliersPage;

View File

@@ -0,0 +1,734 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Store, MapPin, Clock, Settings as SettingsIcon, Save, X, AlertCircle, Loader } from 'lucide-react';
import { Button, Card, Input, Select } from '../../../../components/ui';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { useUpdateTenant } from '../../../../api/hooks/tenant';
import { useCurrentTenant, useTenantActions } from '../../../../stores/tenant.store';
import { useSettings, useUpdateSettings } from '../../../../api/hooks/settings';
import type {
ProcurementSettings,
InventorySettings,
ProductionSettings,
SupplierSettings,
POSSettings,
OrderSettings,
} from '../../../../api/types/settings';
import ProcurementSettingsCard from '../../database/ajustes/cards/ProcurementSettingsCard';
import InventorySettingsCard from '../../database/ajustes/cards/InventorySettingsCard';
import ProductionSettingsCard from '../../database/ajustes/cards/ProductionSettingsCard';
import SupplierSettingsCard from '../../database/ajustes/cards/SupplierSettingsCard';
import POSSettingsCard from '../../database/ajustes/cards/POSSettingsCard';
import OrderSettingsCard from '../../database/ajustes/cards/OrderSettingsCard';
interface BakeryConfig {
name: string;
description: string;
email: string;
phone: string;
website: string;
address: string;
city: string;
postalCode: string;
country: string;
taxId: string;
currency: string;
timezone: string;
language: string;
}
interface BusinessHours {
[key: string]: {
open: string;
close: string;
closed: boolean;
};
}
const BakerySettingsPage: React.FC = () => {
const { t } = useTranslation('settings');
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const { loadUserTenants, setCurrentTenant } = useTenantActions();
const tenantId = currentTenant?.id || '';
const { data: settings, isLoading: settingsLoading } = useSettings(tenantId, {
enabled: !!tenantId,
});
const updateTenantMutation = useUpdateTenant();
const updateSettingsMutation = useUpdateSettings();
const [activeTab, setActiveTab] = useState('information');
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [config, setConfig] = useState<BakeryConfig>({
name: '',
description: '',
email: '',
phone: '',
website: '',
address: '',
city: '',
postalCode: '',
country: '',
taxId: '',
currency: 'EUR',
timezone: 'Europe/Madrid',
language: 'es'
});
const [businessHours, setBusinessHours] = useState<BusinessHours>({
monday: { open: '07:00', close: '20:00', closed: false },
tuesday: { open: '07:00', close: '20:00', closed: false },
wednesday: { open: '07:00', close: '20:00', closed: false },
thursday: { open: '07:00', close: '20:00', closed: false },
friday: { open: '07:00', close: '20:00', closed: false },
saturday: { open: '08:00', close: '14:00', closed: false },
sunday: { open: '09:00', close: '13:00', closed: false }
});
// Operational settings state
const [procurementSettings, setProcurementSettings] = useState<ProcurementSettings | null>(null);
const [inventorySettings, setInventorySettings] = useState<InventorySettings | null>(null);
const [productionSettings, setProductionSettings] = useState<ProductionSettings | null>(null);
const [supplierSettings, setSupplierSettings] = useState<SupplierSettings | null>(null);
const [posSettings, setPosSettings] = useState<POSSettings | null>(null);
const [orderSettings, setOrderSettings] = useState<OrderSettings | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
// Load tenant data
React.useEffect(() => {
loadUserTenants();
}, [loadUserTenants]);
// Update config when tenant data is loaded
React.useEffect(() => {
if (currentTenant) {
setConfig({
name: currentTenant.name || '',
description: currentTenant.description || '',
email: currentTenant.email || '',
phone: currentTenant.phone || '',
website: currentTenant.website || '',
address: currentTenant.address || '',
city: currentTenant.city || '',
postalCode: currentTenant.postal_code || '',
country: currentTenant.country || '',
taxId: '',
currency: 'EUR',
timezone: 'Europe/Madrid',
language: 'es'
});
setHasUnsavedChanges(false);
}
}, [currentTenant]);
// Load settings into local state
React.useEffect(() => {
if (settings) {
setProcurementSettings(settings.procurement_settings);
setInventorySettings(settings.inventory_settings);
setProductionSettings(settings.production_settings);
setSupplierSettings(settings.supplier_settings);
setPosSettings(settings.pos_settings);
setOrderSettings(settings.order_settings);
}
}, [settings]);
const currencyOptions = [
{ value: 'EUR', label: 'EUR (€)' },
{ value: 'USD', label: 'USD ($)' },
{ value: 'GBP', label: 'GBP (£)' }
];
const timezoneOptions = [
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
];
const languageOptions = [
{ value: 'es', label: 'Español' },
{ value: 'eu', label: 'Euskara' },
{ value: 'en', label: 'English' }
];
const validateConfig = (): boolean => {
const newErrors: Record<string, string> = {};
if (!config.name.trim()) {
newErrors.name = t('bakery.information.fields.name') + ' ' + t('common.required');
}
if (!config.email.trim()) {
newErrors.email = t('bakery.information.fields.email') + ' ' + t('common.required');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(config.email)) {
newErrors.email = t('common.error');
}
if (!config.address.trim()) {
newErrors.address = t('bakery.information.fields.address') + ' ' + t('common.required');
}
if (!config.city.trim()) {
newErrors.city = t('bakery.information.fields.city') + ' ' + t('common.required');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSaveConfig = async () => {
if (!validateConfig() || !tenantId) return;
setIsLoading(true);
try {
const updateData = {
name: config.name,
description: config.description,
email: config.email,
phone: config.phone,
website: config.website,
address: config.address,
city: config.city,
postal_code: config.postalCode,
country: config.country
};
const updatedTenant = await updateTenantMutation.mutateAsync({
tenantId,
updateData
});
if (updatedTenant) {
setCurrentTenant(updatedTenant);
await loadUserTenants();
const tenantStorage = localStorage.getItem('tenant-storage');
if (tenantStorage) {
const parsedStorage = JSON.parse(tenantStorage);
if (parsedStorage.state && parsedStorage.state.currentTenant) {
parsedStorage.state.currentTenant = updatedTenant;
localStorage.setItem('tenant-storage', JSON.stringify(parsedStorage));
}
}
}
setHasUnsavedChanges(false);
addToast(t('bakery.save_success'), { type: 'success' });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t('common.error');
addToast(`${t('bakery.save_error')}: ${errorMessage}`, { type: 'error' });
} finally {
setIsLoading(false);
}
};
const handleSaveOperationalSettings = async () => {
if (!tenantId || !procurementSettings || !inventorySettings || !productionSettings ||
!supplierSettings || !posSettings || !orderSettings) {
return;
}
setIsLoading(true);
try {
await updateSettingsMutation.mutateAsync({
tenantId,
updates: {
procurement_settings: procurementSettings,
inventory_settings: inventorySettings,
production_settings: productionSettings,
supplier_settings: supplierSettings,
pos_settings: posSettings,
order_settings: orderSettings,
},
});
setHasUnsavedChanges(false);
addToast(t('bakery.save_success'), { type: 'success' });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t('common.error');
addToast(`${t('bakery.save_error')}: ${errorMessage}`, { type: 'error' });
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: keyof BakeryConfig) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setConfig(prev => ({ ...prev, [field]: e.target.value }));
setHasUnsavedChanges(true);
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleSelectChange = (field: keyof BakeryConfig) => (value: string) => {
setConfig(prev => ({ ...prev, [field]: value }));
setHasUnsavedChanges(true);
};
const handleHoursChange = (day: string, field: 'open' | 'close' | 'closed', value: string | boolean) => {
setBusinessHours(prev => ({
...prev,
[day]: {
...prev[day],
[field]: value
}
}));
setHasUnsavedChanges(true);
};
const handleOperationalSettingsChange = () => {
setHasUnsavedChanges(true);
};
const handleDiscard = () => {
if (currentTenant) {
setConfig({
name: currentTenant.name || '',
description: currentTenant.description || '',
email: currentTenant.email || '',
phone: currentTenant.phone || '',
website: currentTenant.website || '',
address: currentTenant.address || '',
city: currentTenant.city || '',
postalCode: currentTenant.postal_code || '',
country: currentTenant.country || '',
taxId: '',
currency: 'EUR',
timezone: 'Europe/Madrid',
language: 'es'
});
}
if (settings) {
setProcurementSettings(settings.procurement_settings);
setInventorySettings(settings.inventory_settings);
setProductionSettings(settings.production_settings);
setSupplierSettings(settings.supplier_settings);
setPosSettings(settings.pos_settings);
setOrderSettings(settings.order_settings);
}
setHasUnsavedChanges(false);
};
if (!currentTenant || settingsLoading) {
return (
<div className="p-4 sm:p-6 space-y-6">
<PageHeader
title={t('bakery.title')}
description={t('bakery.description')}
/>
<div className="flex items-center justify-center h-64">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<span className="ml-2 text-[var(--text-secondary)]">{t('common.loading')}</span>
</div>
</div>
);
}
const daysOfWeek = [
{ key: 'monday', label: t('bakery.hours.days.monday') },
{ key: 'tuesday', label: t('bakery.hours.days.tuesday') },
{ key: 'wednesday', label: t('bakery.hours.days.wednesday') },
{ key: 'thursday', label: t('bakery.hours.days.thursday') },
{ key: 'friday', label: t('bakery.hours.days.friday') },
{ key: 'saturday', label: t('bakery.hours.days.saturday') },
{ key: 'sunday', label: t('bakery.hours.days.sunday') }
];
return (
<div className="p-4 sm:p-6 space-y-6 pb-32">
<PageHeader
title={t('bakery.title')}
description={t('bakery.description')}
/>
{/* Bakery Header Card */}
<Card className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
<div className="w-16 h-16 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-xl flex-shrink-0">
{config.name.charAt(0) || 'B'}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-xl sm:text-2xl font-bold text-text-primary mb-1 truncate">
{config.name || t('bakery.information.fields.name')}
</h1>
<p className="text-sm sm:text-base text-text-secondary truncate">{config.email}</p>
<p className="text-xs sm:text-sm text-text-tertiary truncate">{config.address}, {config.city}</p>
</div>
{hasUnsavedChanges && (
<div className="flex items-center gap-2 text-sm text-yellow-600 w-full sm:w-auto">
<AlertCircle className="w-4 h-4" />
<span className="hidden sm:inline">{t('bakery.unsaved_changes')}</span>
</div>
)}
</div>
</Card>
{/* Tabs Navigation */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="information" className="flex-1 sm:flex-none whitespace-nowrap">
<Store className="w-4 h-4 mr-2" />
{t('bakery.tabs.information')}
</TabsTrigger>
<TabsTrigger value="hours" className="flex-1 sm:flex-none whitespace-nowrap">
<Clock className="w-4 h-4 mr-2" />
{t('bakery.tabs.hours')}
</TabsTrigger>
<TabsTrigger value="operations" className="flex-1 sm:flex-none whitespace-nowrap">
<SettingsIcon className="w-4 h-4 mr-2" />
{t('bakery.tabs.operations')}
</TabsTrigger>
</TabsList>
{/* Tab 1: Information */}
<TabsContent value="information">
<div className="space-y-6">
{/* General Information */}
<Card className="p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold text-text-primary mb-4 sm:mb-6 flex items-center">
<Store className="w-5 h-5 mr-2" />
{t('bakery.information.general_section')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
<Input
label={t('bakery.information.fields.name')}
value={config.name}
onChange={handleInputChange('name')}
error={errors.name}
disabled={isLoading}
placeholder={t('bakery.information.placeholders.name')}
leftIcon={<Store className="w-4 h-4" />}
/>
<Input
type="email"
label={t('bakery.information.fields.email')}
value={config.email}
onChange={handleInputChange('email')}
error={errors.email}
disabled={isLoading}
placeholder={t('bakery.information.placeholders.email')}
leftIcon={<MapPin className="w-4 h-4" />}
/>
<Input
type="tel"
label={t('bakery.information.fields.phone')}
value={config.phone}
onChange={handleInputChange('phone')}
disabled={isLoading}
placeholder={t('bakery.information.placeholders.phone')}
leftIcon={<Clock className="w-4 h-4" />}
/>
<Input
label={t('bakery.information.fields.website')}
value={config.website}
onChange={handleInputChange('website')}
disabled={isLoading}
placeholder={t('bakery.information.placeholders.website')}
className="md:col-span-2 xl:col-span-3"
/>
</div>
<div className="mt-4 sm:mt-6">
<label className="block text-sm font-medium text-text-secondary mb-2">
{t('bakery.information.fields.description')}
</label>
<textarea
value={config.description}
onChange={handleInputChange('description')}
disabled={isLoading}
rows={3}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg resize-none bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)] text-sm sm:text-base"
placeholder={t('bakery.information.placeholders.description')}
/>
</div>
</Card>
{/* Location Information */}
<Card className="p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold text-text-primary mb-4 sm:mb-6 flex items-center">
<MapPin className="w-5 h-5 mr-2" />
{t('bakery.information.location_section')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
<Input
label={t('bakery.information.fields.address')}
value={config.address}
onChange={handleInputChange('address')}
error={errors.address}
disabled={isLoading}
placeholder={t('bakery.information.placeholders.address')}
leftIcon={<MapPin className="w-4 h-4" />}
className="md:col-span-2"
/>
<Input
label={t('bakery.information.fields.city')}
value={config.city}
onChange={handleInputChange('city')}
error={errors.city}
disabled={isLoading}
placeholder={t('bakery.information.placeholders.city')}
/>
<Input
label={t('bakery.information.fields.postal_code')}
value={config.postalCode}
onChange={handleInputChange('postalCode')}
disabled={isLoading}
placeholder={t('bakery.information.placeholders.postal_code')}
/>
<Input
label={t('bakery.information.fields.country')}
value={config.country}
onChange={handleInputChange('country')}
disabled={isLoading}
placeholder={t('bakery.information.placeholders.country')}
/>
</div>
</Card>
{/* Business Information */}
<Card className="p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold text-text-primary mb-4 sm:mb-6">
{t('bakery.information.business_section')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
<Input
label={t('bakery.information.fields.tax_id')}
value={config.taxId}
onChange={handleInputChange('taxId')}
disabled={isLoading}
placeholder={t('bakery.information.placeholders.tax_id')}
/>
<Select
label={t('bakery.information.fields.currency')}
options={currencyOptions}
value={config.currency}
onChange={(value) => handleSelectChange('currency')(value as string)}
disabled={isLoading}
/>
<Select
label={t('bakery.information.fields.timezone')}
options={timezoneOptions}
value={config.timezone}
onChange={(value) => handleSelectChange('timezone')(value as string)}
disabled={isLoading}
/>
<Select
label={t('bakery.information.fields.language')}
options={languageOptions}
value={config.language}
onChange={(value) => handleSelectChange('language')(value as string)}
disabled={isLoading}
/>
</div>
</Card>
</div>
</TabsContent>
{/* Tab 2: Business Hours */}
<TabsContent value="hours">
<Card className="p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold text-text-primary mb-4 sm:mb-6 flex items-center">
<Clock className="w-5 h-5 mr-2" />
{t('bakery.hours.title')}
</h3>
<div className="space-y-3 sm:space-y-4">
{daysOfWeek.map((day) => {
const hours = businessHours[day.key];
return (
<div key={day.key} className="flex flex-col sm:grid sm:grid-cols-12 gap-3 sm:gap-4 p-3 sm:p-4 border border-border-primary rounded-lg">
{/* Day Name */}
<div className="sm:col-span-2">
<span className="text-sm font-medium text-text-secondary">{day.label}</span>
</div>
{/* Closed Checkbox */}
<div className="sm:col-span-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={hours.closed}
onChange={(e) => handleHoursChange(day.key, 'closed', e.target.checked)}
disabled={isLoading}
className="rounded border-border-primary"
/>
<span className="text-sm text-text-secondary">{t('bakery.hours.closed')}</span>
</label>
</div>
{/* Time Inputs */}
<div className="sm:col-span-8 flex items-center gap-4 sm:gap-6">
{!hours.closed ? (
<>
<div className="flex-1">
<label className="block text-xs text-text-tertiary mb-1">
{t('bakery.hours.open_time')}
</label>
<input
type="time"
value={hours.open}
onChange={(e) => handleHoursChange(day.key, 'open', e.target.value)}
disabled={isLoading}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
/>
</div>
<div className="flex-1">
<label className="block text-xs text-text-tertiary mb-1">
{t('bakery.hours.close_time')}
</label>
<input
type="time"
value={hours.close}
onChange={(e) => handleHoursChange(day.key, 'close', e.target.value)}
disabled={isLoading}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)] disabled:bg-[var(--bg-secondary)] disabled:text-[var(--text-secondary)]"
/>
</div>
</>
) : (
<div className="text-sm text-text-tertiary italic">
{t('bakery.hours.closed_all_day')}
</div>
)}
</div>
</div>
);
})}
</div>
</Card>
</TabsContent>
{/* Tab 3: Operational Settings */}
<TabsContent value="operations">
<div className="space-y-6">
{procurementSettings && (
<ProcurementSettingsCard
settings={procurementSettings}
onChange={(newSettings) => {
setProcurementSettings(newSettings);
handleOperationalSettingsChange();
}}
disabled={isLoading}
/>
)}
{inventorySettings && (
<InventorySettingsCard
settings={inventorySettings}
onChange={(newSettings) => {
setInventorySettings(newSettings);
handleOperationalSettingsChange();
}}
disabled={isLoading}
/>
)}
{productionSettings && (
<ProductionSettingsCard
settings={productionSettings}
onChange={(newSettings) => {
setProductionSettings(newSettings);
handleOperationalSettingsChange();
}}
disabled={isLoading}
/>
)}
{supplierSettings && (
<SupplierSettingsCard
settings={supplierSettings}
onChange={(newSettings) => {
setSupplierSettings(newSettings);
handleOperationalSettingsChange();
}}
disabled={isLoading}
/>
)}
{posSettings && (
<POSSettingsCard
settings={posSettings}
onChange={(newSettings) => {
setPosSettings(newSettings);
handleOperationalSettingsChange();
}}
disabled={isLoading}
/>
)}
{orderSettings && (
<OrderSettingsCard
settings={orderSettings}
onChange={(newSettings) => {
setOrderSettings(newSettings);
handleOperationalSettingsChange();
}}
disabled={isLoading}
/>
)}
</div>
</TabsContent>
</Tabs>
{/* Floating Save Button */}
{hasUnsavedChanges && (
<div className="fixed bottom-6 right-4 sm:right-6 left-4 sm:left-auto z-50">
<Card className="p-3 sm:p-4 shadow-lg border-2 border-[var(--color-primary)]">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<div className="flex items-center gap-2 text-sm text-text-secondary">
<AlertCircle className="w-4 h-4 text-yellow-500 flex-shrink-0" />
<span>{t('bakery.unsaved_changes')}</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDiscard}
disabled={isLoading}
className="flex-1 sm:flex-none"
>
<X className="w-4 h-4 mr-1" />
{t('common.discard')}
</Button>
<Button
variant="primary"
size="sm"
onClick={activeTab === 'operations' ? handleSaveOperationalSettings : handleSaveConfig}
isLoading={isLoading}
loadingText={t('common.saving')}
className="flex-1 sm:flex-none"
>
<Save className="w-4 h-4 mr-1" />
{t('common.save')}
</Button>
</div>
</div>
</Card>
</div>
)}
</div>
);
};
export default BakerySettingsPage;

View File

@@ -1,50 +0,0 @@
import React from 'react';
import { PageHeader } from '../../../../components/layout';
import { useAuthProfile, useUpdateProfile } from '../../../../api/hooks/auth';
import CommunicationPreferences, { type NotificationPreferences } from '../profile/CommunicationPreferences';
const CommunicationPreferencesPage: React.FC = () => {
const { data: profile } = useAuthProfile();
const updateProfileMutation = useUpdateProfile();
const [hasChanges, setHasChanges] = React.useState(false);
const handleSaveNotificationPreferences = async (preferences: NotificationPreferences) => {
try {
await updateProfileMutation.mutateAsync({
language: preferences.language,
timezone: preferences.timezone,
notification_preferences: preferences
});
setHasChanges(false);
} catch (error) {
throw error; // Let the component handle the error display
}
};
const handleResetNotificationPreferences = () => {
setHasChanges(false);
};
return (
<div className="p-6 space-y-6">
<PageHeader
title="Preferencias de Comunicación"
description="Gestiona tus preferencias de notificaciones y comunicación"
/>
<CommunicationPreferences
userEmail={profile?.email || ''}
userPhone={profile?.phone || ''}
userLanguage={profile?.language || 'es'}
userTimezone={profile?.timezone || 'Europe/Madrid'}
onSave={handleSaveNotificationPreferences}
onReset={handleResetNotificationPreferences}
hasChanges={hasChanges}
/>
</div>
);
};
export default CommunicationPreferencesPage;

View File

@@ -1,393 +0,0 @@
import React, { useState } from 'react';
import { User, Mail, Phone, Lock, Globe, Clock, Camera, Save, X } from 'lucide-react';
import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useAuthUser } from '../../../../stores/auth.store';
import { useToast } from '../../../../hooks/ui/useToast';
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
import { useTranslation } from 'react-i18next';
interface ProfileFormData {
first_name: string;
last_name: string;
email: string;
phone: string;
language: string;
timezone: string;
avatar?: string;
}
interface PasswordData {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
const PersonalInfoPage: React.FC = () => {
const user = useAuthUser();
const { t } = useTranslation('auth');
const { addToast } = useToast();
const { data: profile, isLoading: profileLoading, error: profileError } = useAuthProfile();
const updateProfileMutation = useUpdateProfile();
const changePasswordMutation = useChangePassword();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [profileData, setProfileData] = useState<ProfileFormData>({
first_name: '',
last_name: '',
email: '',
phone: '',
language: 'es',
timezone: 'Europe/Madrid'
});
// Update profile data when profile is loaded
React.useEffect(() => {
if (profile) {
setProfileData({
first_name: profile.first_name || '',
last_name: profile.last_name || '',
email: profile.email || '',
phone: profile.phone || '',
language: profile.language || 'es',
timezone: profile.timezone || 'Europe/Madrid',
avatar: profile.avatar || ''
});
}
}, [profile]);
const [passwordData, setPasswordData] = useState<PasswordData>({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [errors, setErrors] = useState<Record<string, string>>({});
const languageOptions = [
{ value: 'es', label: 'Español' },
{ value: 'ca', label: 'Català' },
{ value: 'en', label: 'English' }
];
const timezoneOptions = [
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
];
const validateProfile = (): boolean => {
const newErrors: Record<string, string> = {};
if (!profileData.first_name.trim()) {
newErrors.first_name = 'El nombre es requerido';
}
if (!profileData.last_name.trim()) {
newErrors.last_name = 'Los apellidos son requeridos';
}
if (!profileData.email.trim()) {
newErrors.email = 'El email es requerido';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileData.email)) {
newErrors.email = 'Email inválido';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const validatePassword = (): boolean => {
const newErrors: Record<string, string> = {};
if (!passwordData.currentPassword) {
newErrors.currentPassword = 'Contraseña actual requerida';
}
if (!passwordData.newPassword) {
newErrors.newPassword = 'Nueva contraseña requerida';
} else if (passwordData.newPassword.length < 8) {
newErrors.newPassword = 'Mínimo 8 caracteres';
}
if (passwordData.newPassword !== passwordData.confirmPassword) {
newErrors.confirmPassword = 'Las contraseñas no coinciden';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSaveProfile = async () => {
if (!validateProfile()) return;
setIsLoading(true);
try {
await updateProfileMutation.mutateAsync(profileData);
setIsEditing(false);
addToast('Perfil actualizado correctamente', 'success');
} catch (error) {
addToast('No se pudo actualizar tu perfil', 'error');
} finally {
setIsLoading(false);
}
};
const handleChangePasswordSubmit = async () => {
if (!validatePassword()) return;
setIsLoading(true);
try {
await changePasswordMutation.mutateAsync({
current_password: passwordData.currentPassword,
new_password: passwordData.newPassword,
confirm_password: passwordData.confirmPassword
});
setShowPasswordForm(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
addToast('Contraseña actualizada correctamente', 'success');
} catch (error) {
addToast('No se pudo cambiar tu contraseña', 'error');
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: keyof ProfileFormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setProfileData(prev => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleSelectChange = (field: keyof ProfileFormData) => (value: string) => {
setProfileData(prev => ({ ...prev, [field]: value }));
};
const handlePasswordChange = (field: keyof PasswordData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setPasswordData(prev => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
return (
<div className="p-6 space-y-6">
<PageHeader
title="Información Personal"
description="Gestiona tu información personal y datos de contacto"
/>
{/* Profile Header */}
<Card className="p-6">
<div className="flex items-center gap-6">
<div className="relative">
<Avatar
src={profile?.avatar || undefined}
alt={profile?.full_name || `${profileData.first_name} ${profileData.last_name}` || 'Usuario'}
name={profile?.avatar ? (profile?.full_name || `${profileData.first_name} ${profileData.last_name}`) : undefined}
size="xl"
className="w-20 h-20"
/>
<button className="absolute -bottom-1 -right-1 bg-color-primary text-white rounded-full p-2 shadow-lg hover:bg-color-primary-dark transition-colors">
<Camera className="w-4 h-4" />
</button>
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold text-text-primary mb-1">
{profileData.first_name} {profileData.last_name}
</h1>
<p className="text-text-secondary">{profileData.email}</p>
{user?.role && (
<p className="text-sm text-text-tertiary mt-1">
{t(`global_roles.${user.role}`)}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm text-text-tertiary">En línea</span>
</div>
</div>
<div className="flex gap-2">
{!isEditing && (
<Button
variant="outline"
onClick={() => setIsEditing(true)}
className="flex items-center gap-2"
>
<User className="w-4 h-4" />
Editar Perfil
</Button>
)}
<Button
variant="outline"
onClick={() => setShowPasswordForm(!showPasswordForm)}
className="flex items-center gap-2"
>
<Lock className="w-4 h-4" />
Cambiar Contraseña
</Button>
</div>
</div>
</Card>
{/* Profile Form */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Información Personal</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<Input
label="Nombre"
value={profileData.first_name}
onChange={handleInputChange('first_name')}
error={errors.first_name}
disabled={!isEditing || isLoading}
leftIcon={<User className="w-4 h-4" />}
/>
<Input
label="Apellidos"
value={profileData.last_name}
onChange={handleInputChange('last_name')}
error={errors.last_name}
disabled={!isEditing || isLoading}
/>
<Input
type="email"
label="Correo Electrónico"
value={profileData.email}
onChange={handleInputChange('email')}
error={errors.email}
disabled={!isEditing || isLoading}
leftIcon={<Mail className="w-4 h-4" />}
/>
<Input
type="tel"
label="Teléfono"
value={profileData.phone}
onChange={handleInputChange('phone')}
error={errors.phone}
disabled={!isEditing || isLoading}
placeholder="+34 600 000 000"
leftIcon={<Phone className="w-4 h-4" />}
/>
<Select
label="Idioma"
options={languageOptions}
value={profileData.language}
onChange={handleSelectChange('language')}
disabled={!isEditing || isLoading}
leftIcon={<Globe className="w-4 h-4" />}
/>
<Select
label="Zona Horaria"
options={timezoneOptions}
value={profileData.timezone}
onChange={handleSelectChange('timezone')}
disabled={!isEditing || isLoading}
leftIcon={<Clock className="w-4 h-4" />}
/>
</div>
{isEditing && (
<div className="flex gap-3 mt-6 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsEditing(false)}
disabled={isLoading}
className="flex items-center gap-2"
>
<X className="w-4 h-4" />
Cancelar
</Button>
<Button
variant="primary"
onClick={handleSaveProfile}
isLoading={isLoading}
loadingText="Guardando..."
className="flex items-center gap-2"
>
<Save className="w-4 h-4" />
Guardar Cambios
</Button>
</div>
)}
</Card>
{/* Password Change Form */}
{showPasswordForm && (
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Cambiar Contraseña</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-4xl">
<Input
type="password"
label="Contraseña Actual"
value={passwordData.currentPassword}
onChange={handlePasswordChange('currentPassword')}
error={errors.currentPassword}
disabled={isLoading}
leftIcon={<Lock className="w-4 h-4" />}
/>
<Input
type="password"
label="Nueva Contraseña"
value={passwordData.newPassword}
onChange={handlePasswordChange('newPassword')}
error={errors.newPassword}
disabled={isLoading}
leftIcon={<Lock className="w-4 h-4" />}
/>
<Input
type="password"
label="Confirmar Nueva Contraseña"
value={passwordData.confirmPassword}
onChange={handlePasswordChange('confirmPassword')}
error={errors.confirmPassword}
disabled={isLoading}
leftIcon={<Lock className="w-4 h-4" />}
/>
</div>
<div className="flex gap-3 pt-6 mt-6 border-t">
<Button
variant="outline"
onClick={() => {
setShowPasswordForm(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
setErrors({});
}}
disabled={isLoading}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleChangePasswordSubmit}
isLoading={isLoading}
loadingText="Cambiando..."
>
Cambiar Contraseña
</Button>
</div>
</Card>
)}
</div>
);
};
export default PersonalInfoPage;

View File

@@ -1,547 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
Shield,
Download,
Trash2,
FileText,
Cookie,
AlertTriangle,
CheckCircle,
ExternalLink,
Lock,
Eye
} from 'lucide-react';
import { Button, Card, Input } from '../../../../components/ui';
import { useToast } from '../../../../hooks/ui/useToast';
import { useAuthUser, useAuthStore, useAuthActions } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores';
import { subscriptionService } from '../../../../api';
export const PrivacySettingsPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { success, error: showError } = useToast();
const user = useAuthUser();
const token = useAuthStore((state) => state.token);
const { logout } = useAuthActions();
const currentTenant = useCurrentTenant();
const [isExporting, setIsExporting] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('');
const [deletePassword, setDeletePassword] = useState('');
const [deleteReason, setDeleteReason] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [showExportPreview, setShowExportPreview] = useState(false);
const [exportPreview, setExportPreview] = useState<any>(null);
const [subscriptionStatus, setSubscriptionStatus] = useState<any>(null);
React.useEffect(() => {
const loadSubscriptionStatus = async () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (tenantId) {
try {
const status = await subscriptionService.getSubscriptionStatus(tenantId);
setSubscriptionStatus(status);
} catch (error) {
console.error('Failed to load subscription status:', error);
}
}
};
loadSubscriptionStatus();
}, [currentTenant, user]);
const handleDataExport = async () => {
setIsExporting(true);
try {
const response = await fetch('/api/v1/users/me/export', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to export data');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `my_data_export_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
success(
t('settings:privacy.export_success', 'Your data has been exported successfully'),
{ title: t('settings:privacy.export_complete', 'Export Complete') }
);
} catch (err) {
showError(
t('settings:privacy.export_error', 'Failed to export your data. Please try again.'),
{ title: t('common:error', 'Error') }
);
} finally {
setIsExporting(false);
}
};
const handleViewExportPreview = async () => {
try {
const response = await fetch('/api/v1/users/me/export/summary', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch preview');
}
const data = await response.json();
setExportPreview(data);
setShowExportPreview(true);
} catch (err) {
showError(
t('settings:privacy.preview_error', 'Failed to load preview'),
{ title: t('common:error', 'Error') }
);
}
};
const handleAccountDeletion = async () => {
if (deleteConfirmEmail.toLowerCase() !== user?.email?.toLowerCase()) {
showError(
t('settings:privacy.email_mismatch', 'Email does not match your account email'),
{ title: t('common:error', 'Error') }
);
return;
}
if (!deletePassword) {
showError(
t('settings:privacy.password_required', 'Password is required'),
{ title: t('common:error', 'Error') }
);
return;
}
setIsDeleting(true);
try {
const response = await fetch('/api/v1/users/me/delete/request', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
confirm_email: deleteConfirmEmail,
password: deletePassword,
reason: deleteReason
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to delete account');
}
success(
t('settings:privacy.delete_success', 'Your account has been deleted. You will be logged out.'),
{ title: t('settings:privacy.account_deleted', 'Account Deleted') }
);
setTimeout(() => {
logout();
navigate('/');
}, 2000);
} catch (err: any) {
showError(
err.message || t('settings:privacy.delete_error', 'Failed to delete account. Please try again.'),
{ title: t('common:error', 'Error') }
);
} finally {
setIsDeleting(false);
}
};
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Shield className="w-8 h-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{t('settings:privacy.title', 'Privacy & Data')}
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('settings:privacy.subtitle', 'Manage your data and privacy settings')}
</p>
</div>
</div>
{/* GDPR Rights Information */}
<Card className="p-6 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
{t('settings:privacy.gdpr_rights_title', 'Your Data Rights')}
</h3>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
{t(
'settings:privacy.gdpr_rights_description',
'Under GDPR, you have the right to access, export, and delete your personal data. These tools help you exercise those rights.'
)}
</p>
<div className="flex flex-wrap gap-2">
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline inline-flex items-center gap-1"
>
{t('settings:privacy.privacy_policy', 'Privacy Policy')}
<ExternalLink className="w-3 h-3" />
</a>
<span className="text-gray-400"></span>
<a
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline inline-flex items-center gap-1"
>
{t('settings:privacy.terms', 'Terms of Service')}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
</div>
</Card>
{/* Cookie Preferences */}
<Card className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<Cookie className="w-5 h-5 text-amber-600 mt-1 flex-shrink-0" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('settings:privacy.cookie_preferences', 'Cookie Preferences')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{t(
'settings:privacy.cookie_description',
'Manage which cookies and tracking technologies we can use on your browser.'
)}
</p>
</div>
</div>
<Button
onClick={() => navigate('/cookie-preferences')}
variant="outline"
size="sm"
>
<Cookie className="w-4 h-4 mr-2" />
{t('settings:privacy.manage_cookies', 'Manage Cookies')}
</Button>
</div>
</Card>
{/* Data Export - Article 15 (Right to Access) & Article 20 (Data Portability) */}
<Card className="p-6">
<div className="flex items-start gap-3 mb-4">
<Download className="w-5 h-5 text-green-600 mt-1 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('settings:privacy.export_data', 'Export Your Data')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t(
'settings:privacy.export_description',
'Download a copy of all your personal data in machine-readable JSON format. This includes your profile, account activity, and all data we have about you.'
)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mb-4">
<strong>GDPR Rights:</strong> Article 15 (Right to Access) & Article 20 (Data Portability)
</p>
</div>
</div>
{showExportPreview && exportPreview && (
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h4 className="font-semibold text-sm text-gray-900 dark:text-white mb-3">
{t('settings:privacy.export_preview', 'What will be exported:')}
</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span className="text-gray-700 dark:text-gray-300">Personal data</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span className="text-gray-700 dark:text-gray-300">
{exportPreview.data_counts?.active_sessions || 0} active sessions
</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span className="text-gray-700 dark:text-gray-300">
{exportPreview.data_counts?.consent_changes || 0} consent records
</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span className="text-gray-700 dark:text-gray-300">
{exportPreview.data_counts?.audit_logs || 0} audit logs
</span>
</div>
</div>
</div>
)}
<div className="flex gap-3">
<Button
onClick={handleViewExportPreview}
variant="outline"
size="sm"
disabled={isExporting}
>
<Eye className="w-4 h-4 mr-2" />
{t('settings:privacy.preview_export', 'Preview')}
</Button>
<Button
onClick={handleDataExport}
variant="primary"
size="sm"
disabled={isExporting}
>
<Download className="w-4 h-4 mr-2" />
{isExporting
? t('settings:privacy.exporting', 'Exporting...')
: t('settings:privacy.export_button', 'Export My Data')}
</Button>
</div>
</Card>
{/* Account Deletion - Article 17 (Right to Erasure) */}
<Card className="p-6 border-red-200 dark:border-red-800">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 text-red-600 mt-1 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('settings:privacy.delete_account', 'Delete Account')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t(
'settings:privacy.delete_description',
'Permanently delete your account and all associated data. This action cannot be undone.'
)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mb-4">
<strong>GDPR Right:</strong> Article 17 (Right to Erasure / "Right to be Forgotten")
</p>
</div>
</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
<div className="flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-red-900 dark:text-red-100">
<p className="font-semibold mb-2">
{t('settings:privacy.delete_warning_title', 'What will be deleted:')}
</p>
<ul className="list-disc pl-5 space-y-1 text-xs">
<li>Your account and login credentials</li>
<li>All personal information (name, email, phone)</li>
<li>All active sessions and devices</li>
<li>Consent records and preferences</li>
<li>Security logs and login history</li>
</ul>
<p className="mt-3 font-semibold mb-1">
{t('settings:privacy.delete_retained_title', 'What will be retained:')}
</p>
<ul className="list-disc pl-5 space-y-1 text-xs">
<li>Audit logs (anonymized after 1 year - legal requirement)</li>
<li>Financial records (anonymized for 7 years - tax law)</li>
<li>Aggregated analytics (no personal identifiers)</li>
</ul>
</div>
</div>
</div>
<Button
onClick={() => setShowDeleteModal(true)}
variant="outline"
size="sm"
className="border-red-300 text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4 mr-2" />
{t('settings:privacy.delete_button', 'Delete My Account')}
</Button>
</Card>
{/* Additional Resources */}
<Card className="p-6">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
{t('settings:privacy.resources_title', 'Privacy Resources')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<FileText className="w-5 h-5 text-gray-600" />
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{t('settings:privacy.privacy_policy', 'Privacy Policy')}
</div>
<div className="text-xs text-gray-500">
{t('settings:privacy.privacy_policy_description', 'How we handle your data')}
</div>
</div>
<ExternalLink className="w-4 h-4 text-gray-400" />
</a>
<a
href="/cookies"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<Cookie className="w-5 h-5 text-gray-600" />
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{t('settings:privacy.cookie_policy', 'Cookie Policy')}
</div>
<div className="text-xs text-gray-500">
{t('settings:privacy.cookie_policy_description', 'About cookies we use')}
</div>
</div>
<ExternalLink className="w-4 h-4 text-gray-400" />
</a>
</div>
</Card>
{/* Delete Account Modal */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<Card className="max-w-md w-full p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-red-600 flex-shrink-0" />
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('settings:privacy.delete_confirm_title', 'Delete Account?')}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t(
'settings:privacy.delete_confirm_description',
'This action is permanent and cannot be undone. All your data will be deleted immediately.'
)}
</p>
</div>
</div>
<div className="space-y-4">
{subscriptionStatus && subscriptionStatus.status === 'active' && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold text-yellow-900 dark:text-yellow-100 mb-1">
Active Subscription Detected
</p>
<p className="text-yellow-800 dark:text-yellow-200 mb-2">
You have an active {subscriptionStatus.plan} subscription. Deleting your account will:
</p>
<ul className="list-disc list-inside space-y-1 text-yellow-800 dark:text-yellow-200">
<li>Cancel your subscription immediately</li>
<li>No refund for remaining time</li>
<li>Permanently delete all data</li>
</ul>
</div>
</div>
</div>
)}
<Input
label={t('settings:privacy.confirm_email_label', 'Confirm your email')}
type="email"
placeholder={user?.email || ''}
value={deleteConfirmEmail}
onChange={(e) => setDeleteConfirmEmail(e.target.value)}
required
/>
<Input
label={t('settings:privacy.password_label', 'Enter your password')}
type="password"
placeholder="••••••••"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
required
leftIcon={<Lock className="w-4 h-4" />}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('settings:privacy.delete_reason_label', 'Reason for leaving (optional)')}
</label>
<textarea
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
rows={3}
placeholder={t(
'settings:privacy.delete_reason_placeholder',
'Help us improve by telling us why...'
)}
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
/>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-900 dark:text-amber-100">
{t('settings:privacy.delete_final_warning', 'This will permanently delete your account and all data. This action cannot be reversed.')}
</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<Button
onClick={() => {
setShowDeleteModal(false);
setDeleteConfirmEmail('');
setDeletePassword('');
setDeleteReason('');
}}
variant="outline"
className="flex-1"
disabled={isDeleting}
>
{t('common:actions.cancel', 'Cancel')}
</Button>
<Button
onClick={handleAccountDeletion}
variant="primary"
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
disabled={isDeleting || !deleteConfirmEmail || !deletePassword}
>
{isDeleting
? t('settings:privacy.deleting', 'Deleting...')
: t('settings:privacy.delete_permanently', 'Delete Permanently')}
</Button>
</div>
</Card>
</div>
)}
</div>
);
};
export default PrivacySettingsPage;

View File

@@ -1,2 +0,0 @@
export { default as PrivacySettingsPage } from './PrivacySettingsPage';
export { default } from './PrivacySettingsPage';

View File

@@ -0,0 +1,799 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
User,
Mail,
Phone,
Lock,
Globe,
Clock,
Camera,
Save,
X,
Bell,
Shield,
Download,
Trash2,
AlertCircle,
Cookie,
ExternalLink
} from 'lucide-react';
import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs';
import { PageHeader } from '../../../../components/layout';
import { useToast } from '../../../../hooks/ui/useToast';
import { useAuthUser, useAuthStore, useAuthActions } from '../../../../stores/auth.store';
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
import { useCurrentTenant } from '../../../../stores';
import { subscriptionService } from '../../../../api';
// Import the communication preferences component
import CommunicationPreferences, { type NotificationPreferences } from './CommunicationPreferences';
interface ProfileFormData {
first_name: string;
last_name: string;
email: string;
phone: string;
language: string;
timezone: string;
avatar?: string;
}
interface PasswordData {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
const NewProfileSettingsPage: React.FC = () => {
const { t } = useTranslation('settings');
const navigate = useNavigate();
const { addToast } = useToast();
const user = useAuthUser();
const token = useAuthStore((state) => state.token);
const { logout } = useAuthActions();
const currentTenant = useCurrentTenant();
const { data: profile, isLoading: profileLoading } = useAuthProfile();
const updateProfileMutation = useUpdateProfile();
const changePasswordMutation = useChangePassword();
const [activeTab, setActiveTab] = useState('personal');
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showPasswordForm, setShowPasswordForm] = useState(false);
// Export & Delete states
const [isExporting, setIsExporting] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('');
const [deletePassword, setDeletePassword] = useState('');
const [deleteReason, setDeleteReason] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [subscriptionStatus, setSubscriptionStatus] = useState<any>(null);
const [profileData, setProfileData] = useState<ProfileFormData>({
first_name: '',
last_name: '',
email: '',
phone: '',
language: 'es',
timezone: 'Europe/Madrid'
});
const [passwordData, setPasswordData] = useState<PasswordData>({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Update profile data when profile is loaded
React.useEffect(() => {
if (profile) {
setProfileData({
first_name: profile.first_name || '',
last_name: profile.last_name || '',
email: profile.email || '',
phone: profile.phone || '',
language: profile.language || 'es',
timezone: profile.timezone || 'Europe/Madrid',
avatar: profile.avatar || ''
});
}
}, [profile]);
// Load subscription status
React.useEffect(() => {
const loadSubscriptionStatus = async () => {
const tenantId = currentTenant?.id || user?.tenant_id;
if (tenantId) {
try {
const status = await subscriptionService.getSubscriptionStatus(tenantId);
setSubscriptionStatus(status);
} catch (error) {
console.error('Failed to load subscription status:', error);
}
}
};
loadSubscriptionStatus();
}, [currentTenant, user]);
const languageOptions = [
{ value: 'es', label: 'Español' },
{ value: 'eu', label: 'Euskara' },
{ value: 'en', label: 'English' }
];
const timezoneOptions = [
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
{ value: 'Atlantic/Canary', label: 'Canarias (WET/WEST)' },
{ value: 'Europe/London', label: 'Londres (GMT/BST)' }
];
const validateProfile = (): boolean => {
const newErrors: Record<string, string> = {};
if (!profileData.first_name.trim()) {
newErrors.first_name = t('profile.fields.first_name') + ' ' + t('common.required');
}
if (!profileData.last_name.trim()) {
newErrors.last_name = t('profile.fields.last_name') + ' ' + t('common.required');
}
if (!profileData.email.trim()) {
newErrors.email = t('profile.fields.email') + ' ' + t('common.required');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profileData.email)) {
newErrors.email = t('common.error');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const validatePassword = (): boolean => {
const newErrors: Record<string, string> = {};
if (!passwordData.currentPassword) {
newErrors.currentPassword = t('profile.password.current_password') + ' ' + t('common.required');
}
if (!passwordData.newPassword) {
newErrors.newPassword = t('profile.password.new_password') + ' ' + t('common.required');
} else if (passwordData.newPassword.length < 8) {
newErrors.newPassword = t('profile.password.password_requirements');
}
if (passwordData.newPassword !== passwordData.confirmPassword) {
newErrors.confirmPassword = t('common.error');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSaveProfile = async () => {
if (!validateProfile()) return;
setIsLoading(true);
try {
await updateProfileMutation.mutateAsync(profileData);
setIsEditing(false);
addToast(t('profile.save_changes'), { type: 'success' });
} catch (error) {
addToast(t('common.error'), { type: 'error' });
} finally {
setIsLoading(false);
}
};
const handleChangePasswordSubmit = async () => {
if (!validatePassword()) return;
setIsLoading(true);
try {
await changePasswordMutation.mutateAsync({
current_password: passwordData.currentPassword,
new_password: passwordData.newPassword,
confirm_password: passwordData.confirmPassword
});
setShowPasswordForm(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
addToast(t('profile.password.change_success'), { type: 'success' });
} catch (error) {
addToast(t('profile.password.change_error'), { type: 'error' });
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: keyof ProfileFormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setProfileData(prev => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleSelectChange = (field: keyof ProfileFormData) => (value: string) => {
setProfileData(prev => ({ ...prev, [field]: value }));
};
const handlePasswordChange = (field: keyof PasswordData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setPasswordData(prev => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleSaveNotificationPreferences = async (preferences: NotificationPreferences) => {
try {
await updateProfileMutation.mutateAsync({
language: preferences.language,
timezone: preferences.timezone,
notification_preferences: preferences
});
} catch (error) {
throw error;
}
};
const handleDataExport = async () => {
setIsExporting(true);
try {
const response = await fetch('/api/v1/users/me/export', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to export data');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `my_data_export_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
addToast(t('profile.privacy.export_success'), { type: 'success' });
} catch (err) {
addToast(t('profile.privacy.export_error'), { type: 'error' });
} finally {
setIsExporting(false);
}
};
const handleAccountDeletion = async () => {
if (deleteConfirmEmail.toLowerCase() !== user?.email?.toLowerCase()) {
addToast(t('common.error'), { type: 'error' });
return;
}
if (!deletePassword) {
addToast(t('common.error'), { type: 'error' });
return;
}
setIsDeleting(true);
try {
const response = await fetch('/api/v1/users/me/delete/request', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
confirm_email: deleteConfirmEmail,
password: deletePassword,
reason: deleteReason
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to delete account');
}
addToast(t('common.success'), { type: 'success' });
setTimeout(() => {
logout();
navigate('/');
}, 2000);
} catch (err: any) {
addToast(err.message || t('common.error'), { type: 'error' });
} finally {
setIsDeleting(false);
}
};
if (profileLoading || !profile) {
return (
<div className="p-4 sm:p-6 space-y-6">
<PageHeader
title={t('profile.title')}
description={t('profile.description')}
/>
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 animate-spin rounded-full border-4 border-[var(--color-primary)] border-t-transparent"></div>
<span className="ml-2 text-[var(--text-secondary)]">{t('common.loading')}</span>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 space-y-6 pb-32">
<PageHeader
title={t('profile.title')}
description={t('profile.description')}
/>
{/* Profile Header */}
<Card className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
<div className="relative">
<Avatar
src={profile?.avatar || undefined}
alt={profile?.full_name || `${profileData.first_name} ${profileData.last_name}` || 'Usuario'}
name={profile?.avatar ? (profile?.full_name || `${profileData.first_name} ${profileData.last_name}`) : undefined}
size="xl"
className="w-16 h-16 sm:w-20 sm:h-20"
/>
<button className="absolute -bottom-1 -right-1 bg-color-primary text-white rounded-full p-2 shadow-lg hover:bg-color-primary-dark transition-colors">
<Camera className="w-3 h-3 sm:w-4 sm:h-4" />
</button>
</div>
<div className="flex-1 min-w-0">
<h1 className="text-xl sm:text-2xl font-bold text-text-primary mb-1 truncate">
{profileData.first_name} {profileData.last_name}
</h1>
<p className="text-sm sm:text-base text-text-secondary truncate">{profileData.email}</p>
{user?.role && (
<p className="text-xs sm:text-sm text-text-tertiary mt-1">
{user.role}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-xs sm:text-sm text-text-tertiary">{t('profile.online')}</span>
</div>
</div>
</div>
</Card>
{/* Tabs Navigation */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="personal" className="flex-1 sm:flex-none whitespace-nowrap">
<User className="w-4 h-4 mr-2" />
{t('profile.tabs.personal')}
</TabsTrigger>
<TabsTrigger value="notifications" className="flex-1 sm:flex-none whitespace-nowrap">
<Bell className="w-4 h-4 mr-2" />
{t('profile.tabs.notifications')}
</TabsTrigger>
<TabsTrigger value="privacy" className="flex-1 sm:flex-none whitespace-nowrap">
<Shield className="w-4 h-4 mr-2" />
{t('profile.tabs.privacy')}
</TabsTrigger>
</TabsList>
{/* Tab 1: Personal Information */}
<TabsContent value="personal">
<div className="space-y-6">
{/* Profile Form */}
<Card className="p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-semibold mb-4">{t('profile.personal_info')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6">
<Input
label={t('profile.fields.first_name')}
value={profileData.first_name}
onChange={handleInputChange('first_name')}
error={errors.first_name}
disabled={!isEditing || isLoading}
leftIcon={<User className="w-4 h-4" />}
/>
<Input
label={t('profile.fields.last_name')}
value={profileData.last_name}
onChange={handleInputChange('last_name')}
error={errors.last_name}
disabled={!isEditing || isLoading}
/>
<Input
type="email"
label={t('profile.fields.email')}
value={profileData.email}
onChange={handleInputChange('email')}
error={errors.email}
disabled={!isEditing || isLoading}
leftIcon={<Mail className="w-4 h-4" />}
/>
<Input
type="tel"
label={t('profile.fields.phone')}
value={profileData.phone}
onChange={handleInputChange('phone')}
error={errors.phone}
disabled={!isEditing || isLoading}
placeholder="+34 600 000 000"
leftIcon={<Phone className="w-4 h-4" />}
/>
<Select
label={t('profile.fields.language')}
options={languageOptions}
value={profileData.language}
onChange={handleSelectChange('language')}
disabled={!isEditing || isLoading}
leftIcon={<Globe className="w-4 h-4" />}
/>
<Select
label={t('profile.fields.timezone')}
options={timezoneOptions}
value={profileData.timezone}
onChange={handleSelectChange('timezone')}
disabled={!isEditing || isLoading}
leftIcon={<Clock className="w-4 h-4" />}
/>
</div>
<div className="flex gap-3 mt-6 pt-4 border-t flex-wrap">
{!isEditing ? (
<Button
variant="outline"
onClick={() => setIsEditing(true)}
className="flex items-center gap-2"
>
<User className="w-4 h-4" />
{t('profile.edit_profile')}
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setIsEditing(false)}
disabled={isLoading}
className="flex items-center gap-2"
>
<X className="w-4 h-4" />
{t('profile.cancel')}
</Button>
<Button
variant="primary"
onClick={handleSaveProfile}
isLoading={isLoading}
loadingText={t('common.saving')}
className="flex items-center gap-2"
>
<Save className="w-4 h-4" />
{t('profile.save_changes')}
</Button>
</>
)}
<Button
variant="outline"
onClick={() => setShowPasswordForm(!showPasswordForm)}
className="flex items-center gap-2"
>
<Lock className="w-4 h-4" />
{t('profile.change_password')}
</Button>
</div>
</Card>
{/* Password Change Form */}
{showPasswordForm && (
<Card className="p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-semibold mb-4">{t('profile.password.title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-6 max-w-4xl">
<Input
type="password"
label={t('profile.password.current_password')}
value={passwordData.currentPassword}
onChange={handlePasswordChange('currentPassword')}
error={errors.currentPassword}
disabled={isLoading}
leftIcon={<Lock className="w-4 h-4" />}
/>
<Input
type="password"
label={t('profile.password.new_password')}
value={passwordData.newPassword}
onChange={handlePasswordChange('newPassword')}
error={errors.newPassword}
disabled={isLoading}
leftIcon={<Lock className="w-4 h-4" />}
/>
<Input
type="password"
label={t('profile.password.confirm_password')}
value={passwordData.confirmPassword}
onChange={handlePasswordChange('confirmPassword')}
error={errors.confirmPassword}
disabled={isLoading}
leftIcon={<Lock className="w-4 h-4" />}
/>
</div>
<div className="flex gap-3 pt-6 mt-6 border-t flex-wrap">
<Button
variant="outline"
onClick={() => {
setShowPasswordForm(false);
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
setErrors({});
}}
disabled={isLoading}
>
{t('profile.cancel')}
</Button>
<Button
variant="primary"
onClick={handleChangePasswordSubmit}
isLoading={isLoading}
loadingText={t('common.saving')}
>
{t('profile.password.change_password')}
</Button>
</div>
</Card>
)}
</div>
</TabsContent>
{/* Tab 2: Notifications */}
<TabsContent value="notifications">
<CommunicationPreferences
userEmail={profile?.email || ''}
userPhone={profile?.phone || ''}
userLanguage={profile?.language || 'es'}
userTimezone={profile?.timezone || 'Europe/Madrid'}
onSave={handleSaveNotificationPreferences}
onReset={() => {}}
hasChanges={false}
/>
</TabsContent>
{/* Tab 3: Privacy & Data */}
<TabsContent value="privacy">
<div className="space-y-6">
{/* GDPR Rights Information */}
<Card className="p-4 sm:p-6 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
{t('profile.privacy.gdpr_rights')}
</h3>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
{t('profile.privacy.gdpr_description')}
</p>
<div className="flex flex-wrap gap-2">
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline inline-flex items-center gap-1"
>
{t('profile.privacy.privacy_policy')}
<ExternalLink className="w-3 h-3" />
</a>
<span className="text-gray-400"></span>
<a
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline inline-flex items-center gap-1"
>
{t('profile.privacy.terms')}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
</div>
</Card>
{/* Cookie Preferences */}
<Card className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<Cookie className="w-5 h-5 text-amber-600 mt-1 flex-shrink-0" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('profile.privacy.cookie_preferences')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Gestiona tus preferencias de cookies
</p>
</div>
</div>
<Button
onClick={() => navigate('/cookie-preferences')}
variant="outline"
size="sm"
className="w-full sm:w-auto"
>
<Cookie className="w-4 h-4 mr-2" />
Gestionar
</Button>
</div>
</Card>
{/* Data Export */}
<Card className="p-4 sm:p-6">
<div className="flex items-start gap-3 mb-4">
<Download className="w-5 h-5 text-green-600 mt-1 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('profile.privacy.export_data')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t('profile.privacy.export_description')}
</p>
</div>
</div>
<Button
onClick={handleDataExport}
variant="primary"
size="sm"
disabled={isExporting}
className="w-full sm:w-auto"
>
<Download className="w-4 h-4 mr-2" />
{isExporting ? t('common.loading') : t('profile.privacy.export_button')}
</Button>
</Card>
{/* Account Deletion */}
<Card className="p-4 sm:p-6 border-red-200 dark:border-red-800">
<div className="flex items-start gap-3 mb-4">
<AlertCircle className="w-5 h-5 text-red-600 mt-1 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{t('profile.privacy.delete_account')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t('profile.privacy.delete_description')}
</p>
<p className="text-xs text-red-600 font-semibold">
{t('profile.privacy.delete_warning')}
</p>
</div>
</div>
<Button
onClick={() => setShowDeleteModal(true)}
variant="outline"
size="sm"
className="border-red-300 text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20 w-full sm:w-auto"
>
<Trash2 className="w-4 h-4 mr-2" />
{t('profile.privacy.delete_button')}
</Button>
</Card>
</div>
</TabsContent>
</Tabs>
{/* Delete Account Modal */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<Card className="max-w-md w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-start gap-3 mb-4">
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
<div>
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('profile.privacy.delete_account')}?
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('profile.privacy.delete_warning')}
</p>
</div>
</div>
<div className="space-y-4">
{subscriptionStatus && subscriptionStatus.status === 'active' && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold text-yellow-900 dark:text-yellow-100 mb-1">
Suscripción Activa Detectada
</p>
<p className="text-yellow-800 dark:text-yellow-200">
Tienes una suscripción activa que se cancelará
</p>
</div>
</div>
</div>
)}
<Input
label="Confirma tu email"
type="email"
placeholder={user?.email || ''}
value={deleteConfirmEmail}
onChange={(e) => setDeleteConfirmEmail(e.target.value)}
required
/>
<Input
label="Introduce tu contraseña"
type="password"
placeholder="••••••••"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
required
leftIcon={<Lock className="w-4 h-4" />}
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Motivo (opcional)
</label>
<textarea
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent text-sm"
rows={3}
placeholder="Ayúdanos a mejorar..."
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
/>
</div>
</div>
<div className="flex gap-3 mt-6 flex-wrap">
<Button
onClick={() => {
setShowDeleteModal(false);
setDeleteConfirmEmail('');
setDeletePassword('');
setDeleteReason('');
}}
variant="outline"
className="flex-1"
disabled={isDeleting}
>
{t('common.cancel')}
</Button>
<Button
onClick={handleAccountDeletion}
variant="primary"
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
disabled={isDeleting || !deleteConfirmEmail || !deletePassword}
>
{isDeleting ? t('common.loading') : t('common.delete')}
</Button>
</div>
</Card>
</div>
)}
</div>
);
};
export default NewProfileSettingsPage;

View File

@@ -363,7 +363,7 @@ const ProfilePage: React.FC = () => {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Mi Perfil"
title="Ajustes"
description="Gestiona tu información personal y preferencias de comunicación"
/>

View File

@@ -4,8 +4,7 @@ import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-re
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
import { PageHeader } from '../../../../components/layout';
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
import { useAllUsers } from '../../../../api/hooks/user';
import { useTeamMembers, useAddTeamMember, useAddTeamMemberWithUserCreation, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
@@ -26,15 +25,12 @@ const TeamPage: React.FC = () => {
currentUser?.id || '',
{ enabled: !!tenantId && !!currentUser?.id && !currentTenantAccess }
);
const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive
const { data: allUsers = [], error: allUsersError, isLoading: allUsersLoading } = useAllUsers({
retry: false, // Don't retry on permission errors
staleTime: 0 // Always fresh check for permissions
});
// Mutations
const addMemberMutation = useAddTeamMember();
const addMemberWithUserMutation = useAddTeamMemberWithUserCreation();
const removeMemberMutation = useRemoveTeamMember();
const updateRoleMutation = useUpdateMemberRole();
@@ -46,37 +42,35 @@ const TeamPage: React.FC = () => {
// Enhanced team members that includes owner information
// Note: Backend now enriches members with user info, so we just need to ensure owner is present
const enhancedTeamMembers = useMemo(() => {
const members = [...teamMembers];
// If tenant owner is not in the members list, add them
// If tenant owner is not in the members list, add them as a placeholder
if (currentTenant?.owner_id) {
const ownerInMembers = members.find(m => m.user_id === currentTenant.owner_id);
if (!ownerInMembers) {
// Find owner user data
const ownerUser = allUsers.find(u => u.id === currentTenant.owner_id);
if (ownerUser) {
// Add owner as a member
members.push({
id: `owner-${currentTenant.owner_id}`,
tenant_id: tenantId,
user_id: currentTenant.owner_id,
role: TENANT_ROLES.OWNER,
is_active: true,
joined_at: currentTenant.created_at,
user_email: ownerUser.email,
user_full_name: ownerUser.full_name,
user: ownerUser, // Add full user object for compatibility
} as any);
}
// Add owner as a member with basic info
// Note: The backend should ideally include the owner in the members list
members.push({
id: `owner-${currentTenant.owner_id}`,
tenant_id: tenantId,
user_id: currentTenant.owner_id,
role: TENANT_ROLES.OWNER,
is_active: true,
joined_at: currentTenant.created_at,
user_email: null, // Backend will enrich this
user_full_name: null, // Backend will enrich this
user: null,
} as any);
} else if (ownerInMembers.role !== TENANT_ROLES.OWNER) {
// Update existing member to owner role
ownerInMembers.role = TENANT_ROLES.OWNER;
}
}
return members;
}, [teamMembers, currentTenant, allUsers, tenantId]);
}, [teamMembers, currentTenant, tenantId]);
const roles = [
{ value: 'all', label: 'Todos los Roles', count: enhancedTeamMembers.length },
@@ -160,36 +154,54 @@ const TeamPage: React.FC = () => {
const getMemberActions = (member: any) => {
const actions = [];
// Role change actions (only for non-owners and if user can manage team)
// Primary action - View details (always available)
// This will be implemented in the future to show detailed member info modal
// For now, we can comment it out as there's no modal yet
// actions.push({
// label: 'Ver Detalles',
// icon: Eye,
// priority: 'primary' as const,
// onClick: () => {
// // TODO: Implement member details modal
// console.log('View member details:', member.user_id);
// },
// });
// Contextual role change actions (only for non-owners and if user can manage team)
if (canManageTeam && member.role !== TENANT_ROLES.OWNER) {
if (member.role !== TENANT_ROLES.ADMIN) {
// Promote/demote to most logical next role
if (member.role === TENANT_ROLES.VIEWER) {
// Viewer -> Member (promote)
actions.push({
label: 'Hacer Admin',
icon: Shield,
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.ADMIN),
priority: 'secondary' as const,
});
}
if (member.role !== TENANT_ROLES.MEMBER) {
actions.push({
label: 'Hacer Miembro',
icon: Users,
label: 'Promover a Miembro',
icon: UserCheck,
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.MEMBER),
priority: 'secondary' as const,
});
}
if (member.role !== TENANT_ROLES.VIEWER) {
actions.push({
label: 'Hacer Observador',
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.VIEWER),
priority: 'tertiary' as const,
});
} else if (member.role === TENANT_ROLES.MEMBER) {
// Member -> Admin (promote) or Member -> Viewer (demote)
if (isOwner) {
actions.push({
label: 'Promover a Admin',
icon: Shield,
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.ADMIN),
priority: 'secondary' as const,
});
}
} else if (member.role === TENANT_ROLES.ADMIN) {
// Admin -> Member (demote) - only owner can do this
if (isOwner) {
actions.push({
label: 'Cambiar a Miembro',
icon: Users,
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.MEMBER),
priority: 'secondary' as const,
});
}
}
}
// Remove member action (only for owners)
// Remove member action (only for owners and non-owner members)
if (isOwner && member.role !== TENANT_ROLES.OWNER) {
actions.push({
label: 'Remover',
@@ -199,7 +211,7 @@ const TeamPage: React.FC = () => {
handleRemoveMember(member.user_id);
}
},
priority: 'tertiary' as const,
priority: 'secondary' as const,
destructive: true,
});
}
@@ -216,11 +228,6 @@ const TeamPage: React.FC = () => {
return matchesRole && matchesSearch;
});
// Available users for adding (exclude current members)
const availableUsers = allUsers.filter(u =>
!enhancedTeamMembers.some(m => m.user_id === u.id)
);
// Force reload tenant access if missing
React.useEffect(() => {
if (currentTenant?.id && !currentTenantAccess) {
@@ -241,10 +248,6 @@ const TeamPage: React.FC = () => {
directTenantAccess,
effectiveTenantAccess,
tenantAccess: effectiveTenantAccess?.role,
allUsers: allUsers.length,
allUsersError,
allUsersLoading,
availableUsers: availableUsers.length,
enhancedTeamMembers: enhancedTeamMembers.length
});
@@ -458,13 +461,31 @@ const TeamPage: React.FC = () => {
throw new Error(errorMessage);
}
await addMemberMutation.mutateAsync({
tenantId,
userId: userData.userId,
role: userData.role,
});
// Use appropriate mutation based on whether we're creating a user
if (userData.createUser) {
await addMemberWithUserMutation.mutateAsync({
tenantId,
memberData: {
create_user: true,
email: userData.email!,
full_name: userData.fullName!,
password: userData.password!,
phone: userData.phone,
role: userData.role,
language: 'es',
timezone: 'Europe/Madrid'
}
});
addToast('Usuario creado y agregado exitosamente', { type: 'success' });
} else {
await addMemberMutation.mutateAsync({
tenantId,
userId: userData.userId!,
role: userData.role,
});
addToast('Miembro agregado exitosamente', { type: 'success' });
}
addToast('Miembro agregado exitosamente', { type: 'success' });
setShowAddForm(false);
setSelectedUserToAdd('');
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
@@ -473,11 +494,14 @@ const TeamPage: React.FC = () => {
// Limit error already toasted above
throw error;
}
addToast('Error al agregar miembro', { type: 'error' });
addToast(
userData.createUser ? 'Error al crear usuario' : 'Error al agregar miembro',
{ type: 'error' }
);
throw error;
}
}}
availableUsers={availableUsers}
availableUsers={[]}
/>
</div>
);

View File

@@ -701,49 +701,71 @@ const LandingPage: React.FC = () => {
</div>
{/* Grant Programs Grid */}
<div className="mt-16 grid md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
<Award className="w-6 h-6 text-blue-600" />
</div>
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.eu_horizon', 'EU Horizon Europe')}</h4>
<p className="text-xs text-[var(--text-secondary)] mb-2">{t('landing:sustainability.grants.eu_horizon_req', 'Requires 30% reduction')}</p>
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
<CheckCircle2 className="w-3 h-3" />
{t('landing:sustainability.grants.eligible', 'Eligible')}
</div>
</div>
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-green-600" />
</div>
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.farm_to_fork', 'Farm to Fork')}</h4>
<p className="text-xs text-[var(--text-secondary)] mb-2">{t('landing:sustainability.grants.farm_to_fork_req', 'Requires 20% reduction')}</p>
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
<CheckCircle2 className="w-3 h-3" />
{t('landing:sustainability.grants.eligible', 'Eligible')}
</div>
</div>
<div className="mt-16 grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6">
{/* LIFE Programme */}
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
<Recycle className="w-6 h-6 text-purple-600" />
</div>
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.circular_economy', 'Circular Economy')}</h4>
<p className="text-xs text-[var(--text-secondary)] mb-2">{t('landing:sustainability.grants.circular_economy_req', 'Requires 15% reduction')}</p>
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.life_circular_economy', 'LIFE Programme')}</h4>
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.life_circular_economy_req', 'Requires 15% reduction')}</p>
<p className="text-xs font-semibold text-purple-600 dark:text-purple-400 mb-2">{t('landing:sustainability.grants.life_circular_economy_funding', '€73M available')}</p>
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
<CheckCircle2 className="w-3 h-3" />
{t('landing:sustainability.grants.eligible', 'Eligible')}
</div>
</div>
{/* Horizon Europe */}
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
<Award className="w-6 h-6 text-blue-600" />
</div>
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.horizon_europe_cluster_6', 'Horizon Europe')}</h4>
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.horizon_europe_cluster_6_req', 'Requires 20% reduction')}</p>
<p className="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-2">{t('landing:sustainability.grants.horizon_europe_cluster_6_funding', '€880M+ annually')}</p>
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
<CheckCircle2 className="w-3 h-3" />
{t('landing:sustainability.grants.eligible', 'Eligible')}
</div>
</div>
{/* Fedima Grant */}
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
<div className="w-12 h-12 bg-orange-100 dark:bg-orange-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-orange-600" />
</div>
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.fedima_sustainability_grant', 'Fedima Grant')}</h4>
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.fedima_sustainability_grant_req', 'Requires 15% reduction')}</p>
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400 mb-2">{t('landing:sustainability.grants.fedima_sustainability_grant_funding', '€20,000 per award')}</p>
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
<CheckCircle2 className="w-3 h-3" />
{t('landing:sustainability.grants.eligible', 'Eligible')}
</div>
</div>
{/* EIT Food */}
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-green-500 transition-all duration-300 text-center">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
<Leaf className="w-6 h-6 text-green-600" />
</div>
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.eit_food_retail', 'EIT Food')}</h4>
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.eit_food_retail_req', 'Requires 20% reduction')}</p>
<p className="text-xs font-semibold text-green-600 dark:text-green-400 mb-2">{t('landing:sustainability.grants.eit_food_retail_funding', '€15-45k per project')}</p>
<div className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-xs font-semibold">
<CheckCircle2 className="w-3 h-3" />
{t('landing:sustainability.grants.eligible', 'Eligible')}
</div>
</div>
{/* UN SDG Certification */}
<div className="bg-white dark:bg-[var(--bg-primary)] rounded-xl p-6 border border-[var(--border-primary)] hover:border-amber-500 transition-all duration-300 text-center">
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center mx-auto mb-4">
<Target className="w-6 h-6 text-amber-600" />
</div>
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.un_sdg', 'UN SDG Certified')}</h4>
<p className="text-xs text-[var(--text-secondary)] mb-2">{t('landing:sustainability.grants.un_sdg_req', 'Requires 50% reduction')}</p>
<h4 className="font-bold text-[var(--text-primary)] mb-2">{t('landing:sustainability.grants.un_sdg', 'UN SDG 12.3')}</h4>
<p className="text-xs text-[var(--text-secondary)] mb-1">{t('landing:sustainability.grants.un_sdg_req', 'Requires 50% reduction')}</p>
<p className="text-xs font-semibold text-amber-600 dark:text-amber-400 mb-2">Certification</p>
<div className="inline-flex items-center gap-1 px-3 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-xs font-semibold">
<TrendingUp className="w-3 h-3" />
{t('landing:sustainability.grants.on_track', 'On Track')}

View File

@@ -42,13 +42,10 @@ const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insigh
const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage'));
// Settings pages
const PersonalInfoPage = React.lazy(() => import('../pages/app/settings/personal-info/PersonalInfoPage'));
const CommunicationPreferencesPage = React.lazy(() => import('../pages/app/settings/communication-preferences/CommunicationPreferencesPage'));
// Settings pages - Unified
const BakerySettingsPage = React.lazy(() => import('../pages/app/settings/bakery/BakerySettingsPage'));
const NewProfileSettingsPage = React.lazy(() => import('../pages/app/settings/profile/NewProfileSettingsPage'));
const SubscriptionPage = React.lazy(() => import('../pages/app/settings/subscription/SubscriptionPage'));
const PrivacySettingsPage = React.lazy(() => import('../pages/app/settings/privacy/PrivacySettingsPage'));
const InformationPage = React.lazy(() => import('../pages/app/database/information/InformationPage'));
const AjustesPage = React.lazy(() => import('../pages/app/database/ajustes/AjustesPage'));
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
const OrganizationsPage = React.lazy(() => import('../pages/app/settings/organizations/OrganizationsPage'));
@@ -197,26 +194,20 @@ export const AppRouter: React.FC = () => {
</ProtectedRoute>
}
/>
{/* NEW: Unified Bakery Settings Route */}
<Route
path="/app/database/information"
path="/app/settings/bakery"
element={
<ProtectedRoute>
<AppShell>
<InformationPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/app/database/ajustes"
element={
<ProtectedRoute>
<AppShell>
<AjustesPage />
<BakerySettingsPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Legacy routes redirect to new unified page */}
<Route path="/app/database/information" element={<Navigate to="/app/settings/bakery" replace />} />
<Route path="/app/database/ajustes" element={<Navigate to="/app/settings/bakery" replace />} />
<Route
path="/app/database/team"
element={
@@ -330,28 +321,23 @@ export const AppRouter: React.FC = () => {
}
/>
{/* Settings Routes */}
{/* NEW: Unified Profile Settings Route */}
<Route
path="/app/settings/personal-info"
path="/app/settings/profile"
element={
<ProtectedRoute>
<AppShell>
<PersonalInfoPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/app/settings/communication-preferences"
element={
<ProtectedRoute>
<AppShell>
<CommunicationPreferencesPage />
<NewProfileSettingsPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Legacy routes redirect to new unified profile page */}
<Route path="/app/settings/personal-info" element={<Navigate to="/app/settings/profile" replace />} />
<Route path="/app/settings/communication-preferences" element={<Navigate to="/app/settings/profile" replace />} />
<Route path="/app/settings/privacy" element={<Navigate to="/app/settings/profile" replace />} />
<Route
path="/app/settings/subscription"
element={
@@ -372,16 +358,6 @@ export const AppRouter: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/app/settings/privacy"
element={
<ProtectedRoute>
<AppShell>
<PrivacySettingsPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Data Routes */}
<Route

View File

@@ -129,18 +129,21 @@ export const ROUTES = {
// Settings
SETTINGS: '/settings',
SETTINGS_PROFILE: '/app/settings/personal-info',
SETTINGS_COMMUNICATION: '/app/settings/communication-preferences',
SETTINGS_PROFILE: '/app/settings/profile',
SETTINGS_BAKERY: '/app/settings/bakery',
SETTINGS_SUBSCRIPTION: '/app/settings/subscription',
SETTINGS_ORGANIZATIONS: '/app/settings/organizations',
SETTINGS_PRIVACY: '/app/settings/privacy',
// Legacy routes (will redirect)
SETTINGS_PERSONAL_INFO_OLD: '/app/settings/personal-info',
SETTINGS_COMMUNICATION_OLD: '/app/settings/communication-preferences',
SETTINGS_PRIVACY_OLD: '/app/settings/privacy',
SETTINGS_BAKERY_INFO_OLD: '/app/database/information',
SETTINGS_BAKERY_AJUSTES_OLD: '/app/database/ajustes',
SETTINGS_TENANT: '/settings/tenant',
SETTINGS_USERS: '/settings/users',
SETTINGS_PERMISSIONS: '/settings/permissions',
SETTINGS_INTEGRATIONS: '/settings/integrations',
SETTINGS_BILLING: '/settings/billing',
SETTINGS_BAKERY_CONFIG: '/app/database/information',
SETTINGS_BAKERY_AJUSTES: '/app/database/ajustes',
SETTINGS_TEAM: '/app/database/team',
QUALITY_TEMPLATES: '/app/database/quality-templates',
@@ -382,21 +385,10 @@ export const routesConfig: RouteConfig[] = [
requiresAuth: true,
showInNavigation: true,
children: [
{
path: '/app/database/information',
name: 'Information',
component: 'InformationPage',
title: 'Información',
icon: 'settings',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/ajustes',
name: 'Ajustes',
component: 'AjustesPage',
path: '/app/settings/bakery',
name: 'BakerySettings',
component: 'BakerySettingsPage',
title: 'Ajustes',
icon: 'settings',
requiresAuth: true,
@@ -500,25 +492,15 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
children: [
{
path: '/app/settings/personal-info',
name: 'PersonalInfo',
component: 'PersonalInfoPage',
title: 'Información',
path: '/app/settings/profile',
name: 'Profile',
component: 'NewProfileSettingsPage',
title: 'Ajustes',
icon: 'user',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/settings/communication-preferences',
name: 'CommunicationPreferences',
component: 'CommunicationPreferencesPage',
title: 'Notificaciones',
icon: 'bell',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/settings/subscription',
name: 'Subscription',
@@ -539,16 +521,6 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/settings/privacy',
name: 'Privacy',
component: 'PrivacySettingsPage',
title: 'Privacidad',
icon: 'settings',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
],
},