Improve the frontend and fix TODOs

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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