Improve the frontend and fix TODOs
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user