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