457 lines
22 KiB
TypeScript
457 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { SetupStepProps } from '../types';
|
|
import { useQualityTemplates, useCreateQualityTemplate } from '../../../../api/hooks/qualityTemplates';
|
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
|
import { useAuthUser } from '../../../../stores/auth.store';
|
|
import { QualityCheckType, ProcessStage, QualityCheckTemplate, QualityCheckTemplateCreate } from '../../../../api/types/qualityTemplates';
|
|
|
|
export const QualitySetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplete, onPrevious, canContinue }) => {
|
|
const { t } = useTranslation();
|
|
|
|
// Get tenant ID and user
|
|
const currentTenant = useCurrentTenant();
|
|
const user = useAuthUser();
|
|
const tenantId = currentTenant?.id || user?.tenant_id || '';
|
|
const userId = user?.id; // Keep undefined if not available - backend requires valid UUID
|
|
|
|
// Fetch quality templates
|
|
const { data: templatesData, isLoading } = useQualityTemplates(tenantId);
|
|
const templates = templatesData?.templates || [];
|
|
|
|
// Mutations
|
|
const createTemplateMutation = useCreateQualityTemplate(tenantId);
|
|
|
|
// Form state
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
check_type: QualityCheckType.VISUAL,
|
|
description: '',
|
|
applicable_stages: [] as ProcessStage[],
|
|
is_required: false,
|
|
is_critical: false,
|
|
});
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
// Notify parent when count changes
|
|
useEffect(() => {
|
|
const count = templates.length;
|
|
onUpdate?.({
|
|
itemsCount: count,
|
|
canContinue: true, // Always allow continuing since this step is optional
|
|
});
|
|
}, [templates.length, onUpdate]);
|
|
|
|
// Validation
|
|
const validateForm = (): boolean => {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
if (!userId) {
|
|
newErrors.form = t('common:error_loading_user', 'User not loaded. Please wait or refresh the page.');
|
|
setErrors(newErrors);
|
|
return false;
|
|
}
|
|
|
|
if (!formData.name.trim()) {
|
|
newErrors.name = t('setup_wizard:quality.errors.name_required', 'Name is required');
|
|
}
|
|
|
|
if (formData.applicable_stages.length === 0) {
|
|
newErrors.stages = t('setup_wizard:quality.errors.stages_required', 'At least one stage is required');
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
// Form handlers
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!validateForm()) return;
|
|
|
|
try {
|
|
const templateData: QualityCheckTemplateCreate = {
|
|
name: formData.name,
|
|
check_type: (formData.check_type as QualityCheckType) || QualityCheckType.VISUAL,
|
|
description: formData.description || undefined,
|
|
applicable_stages: formData.applicable_stages,
|
|
is_required: formData.is_required,
|
|
is_critical: formData.is_critical,
|
|
is_active: true,
|
|
weight: formData.is_critical ? 10 : 5,
|
|
created_by: userId || '',
|
|
};
|
|
|
|
await createTemplateMutation.mutateAsync(templateData);
|
|
|
|
// Reset form
|
|
resetForm();
|
|
} catch (error) {
|
|
console.error('Error saving quality template:', error);
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setFormData({
|
|
name: '',
|
|
check_type: QualityCheckType.VISUAL,
|
|
description: '',
|
|
applicable_stages: [],
|
|
is_required: false,
|
|
is_critical: false,
|
|
});
|
|
setErrors({});
|
|
setIsAdding(false);
|
|
};
|
|
|
|
const toggleStage = (stage: ProcessStage) => {
|
|
const stages = formData.applicable_stages.includes(stage)
|
|
? formData.applicable_stages.filter((s) => s !== stage)
|
|
: [...formData.applicable_stages, stage];
|
|
setFormData({ ...formData, applicable_stages: stages });
|
|
};
|
|
|
|
const checkTypeOptions = [
|
|
{ value: QualityCheckType.VISUAL, label: t('quality:type.visual', 'Visual Inspection'), icon: '👁️' },
|
|
{ value: QualityCheckType.MEASUREMENT, label: t('quality:type.measurement', 'Measurement'), icon: '📏' },
|
|
{ value: QualityCheckType.TEMPERATURE, label: t('quality:type.temperature', 'Temperature'), icon: '🌡️' },
|
|
{ value: QualityCheckType.WEIGHT, label: t('quality:type.weight', 'Weight'), icon: '⚖️' },
|
|
{ value: QualityCheckType.TIMING, label: t('quality:type.timing', 'Timing'), icon: '⏱️' },
|
|
{ value: QualityCheckType.CHECKLIST, label: t('quality:type.checklist', 'Checklist'), icon: '✅' },
|
|
];
|
|
|
|
const stageOptions = [
|
|
{ value: ProcessStage.MIXING, label: t('quality:stage.mixing', 'Mixing') },
|
|
{ value: ProcessStage.PROOFING, label: t('quality:stage.proofing', 'Proofing') },
|
|
{ value: ProcessStage.SHAPING, label: t('quality:stage.shaping', 'Shaping') },
|
|
{ value: ProcessStage.BAKING, label: t('quality:stage.baking', 'Baking') },
|
|
{ value: ProcessStage.COOLING, label: t('quality:stage.cooling', 'Cooling') },
|
|
{ value: ProcessStage.FINISHING, label: t('quality:stage.finishing', 'Finishing') },
|
|
{ value: ProcessStage.PACKAGING, label: t('quality:stage.packaging', 'Packaging') },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Why This Matters */}
|
|
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
|
|
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
|
<svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
{t('setup_wizard:why_this_matters', 'Why This Matters')}
|
|
</h3>
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
{t('setup_wizard:quality.why', 'Quality checks ensure consistent output and help you identify issues early. Define what "good" looks like for each stage of production.')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Optional badge */}
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="px-2 py-1 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-full border border-[var(--border-secondary)]">
|
|
{t('setup_wizard:optional', 'Optional')}
|
|
</span>
|
|
<span className="text-[var(--text-tertiary)]">
|
|
{t('setup_wizard:quality.optional_note', 'You can skip this and configure quality checks later')}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Progress indicator */}
|
|
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
|
{t('setup_wizard:quality.added_count', { count: templates.length, defaultValue: '{count} quality check added' })}
|
|
</span>
|
|
</div>
|
|
{templates.length >= 2 ? (
|
|
<div className="flex items-center gap-1 text-xs text-[var(--color-success)]">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
{t('setup_wizard:quality.recommended_met', 'Recommended amount met')}
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-[var(--text-tertiary)]">
|
|
{t('setup_wizard:quality.recommended', '2+ recommended (optional)')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Templates list */}
|
|
{templates.length > 0 && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-[var(--text-secondary)]">
|
|
{t('setup_wizard:quality.your_checks', 'Your Quality Checks')}
|
|
</h4>
|
|
<div className="space-y-2 max-h-80 overflow-y-auto">
|
|
{templates.map((template) => (
|
|
<div
|
|
key={template.id}
|
|
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--border-primary)] transition-colors"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h5 className="font-medium text-[var(--text-primary)] truncate">{template.name}</h5>
|
|
{template.is_critical && (
|
|
<span className="text-xs px-2 py-0.5 bg-[var(--color-error)]/10 text-[var(--color-error)] rounded-full">
|
|
{t('quality:critical', 'Critical')}
|
|
</span>
|
|
)}
|
|
{template.is_required && (
|
|
<span className="text-xs px-2 py-0.5 bg-[var(--color-warning)]/10 text-[var(--color-warning)] rounded-full">
|
|
{t('quality:required', 'Required')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--text-secondary)]">
|
|
<span className="px-2 py-0.5 bg-[var(--bg-primary)] rounded-full">
|
|
{checkTypeOptions.find(opt => opt.value === template.check_type)?.label || template.check_type}
|
|
</span>
|
|
{template.applicable_stages && template.applicable_stages.length > 0 && (
|
|
<span>
|
|
{template.applicable_stages.length} {t('quality:stages', 'stage(s)')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add form */}
|
|
{isAdding ? (
|
|
<form onSubmit={handleSubmit} className="space-y-4 p-4 border-2 border-[var(--color-primary)] rounded-lg bg-[var(--bg-secondary)]">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="font-medium text-[var(--text-primary)]">
|
|
{t('setup_wizard:quality.add_check', 'Add Quality Check')}
|
|
</h4>
|
|
<button
|
|
type="button"
|
|
onClick={resetForm}
|
|
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
>
|
|
{t('common:cancel', 'Cancel')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{/* Name */}
|
|
<div>
|
|
<label htmlFor="check-name" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
{t('setup_wizard:quality.fields.name', 'Check Name')} <span className="text-[var(--color-error)]">*</span>
|
|
</label>
|
|
<input
|
|
id="check-name"
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className={`w - full px - 3 py - 2 bg - [var(--bg - primary)] border ${errors.name ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded - lg focus: outline - none focus: ring - 2 focus: ring - [var(--color - primary)]text - [var(--text - primary)]`}
|
|
placeholder={t('setup_wizard:quality.placeholders.name', 'e.g., Crust color check, Dough temperature')}
|
|
/>
|
|
{errors.name && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.name}</p>}
|
|
</div>
|
|
|
|
{/* Check Type */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
|
{t('setup_wizard:quality.fields.check_type', 'Check Type')} <span className="text-[var(--color-error)]">*</span>
|
|
</label>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
{checkTypeOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
console.log('Check type clicked:', option.value, 'current:', formData.check_type);
|
|
setFormData(prev => ({ ...prev, check_type: option.value }));
|
|
}}
|
|
className={`p - 3 text - left border - 2 rounded - lg transition - all cursor - pointer ${formData.check_type === option.value
|
|
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/20 shadow-lg ring-2 ring-[var(--color-primary)]/30'
|
|
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
|
|
} `}
|
|
>
|
|
<div className="text-lg mb-1">{option.icon}</div>
|
|
<div className="text-xs font-medium text-[var(--text-primary)]">{option.label}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label htmlFor="description" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
{t('setup_wizard:quality.fields.description', 'Description')}
|
|
</label>
|
|
<textarea
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
rows={2}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)] resize-none"
|
|
placeholder={t('setup_wizard:quality.placeholders.description', 'What should be checked and why...')}
|
|
/>
|
|
</div>
|
|
|
|
{/* Applicable Stages */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
|
{t('setup_wizard:quality.fields.stages', 'Applicable Stages')} <span className="text-[var(--color-error)]">*</span>
|
|
</label>
|
|
{errors.stages && <p className="mb-2 text-xs text-[var(--color-error)]">{errors.stages}</p>}
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
{stageOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
console.log('Stage clicked:', option.value);
|
|
const isSelected = formData.applicable_stages.includes(option.value);
|
|
setFormData(prev => ({
|
|
...prev,
|
|
applicable_stages: isSelected
|
|
? prev.applicable_stages.filter(s => s !== option.value)
|
|
: [...prev.applicable_stages, option.value]
|
|
}));
|
|
}}
|
|
className={`p - 2 text - sm text - left border - 2 rounded - lg transition - all cursor - pointer ${formData.applicable_stages.includes(option.value)
|
|
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/20 text-[var(--color-primary)] font-semibold shadow-md ring-1 ring-[var(--color-primary)]/30'
|
|
: 'border-[var(--border-secondary)] text-[var(--text-secondary)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
|
|
} `}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Flags */}
|
|
<div className="space-y-2">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_required}
|
|
onChange={(e) => setFormData({ ...formData, is_required: e.target.checked })}
|
|
className="w-4 h-4 text-[var(--color-primary)] rounded focus:ring-2 focus:ring-[var(--color-primary)]"
|
|
/>
|
|
<span className="text-sm text-[var(--text-primary)]">
|
|
{t('setup_wizard:quality.fields.required', 'Required check (must be completed)')}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_critical}
|
|
onChange={(e) => setFormData({ ...formData, is_critical: e.target.checked })}
|
|
className="w-4 h-4 text-[var(--color-error)] rounded focus:ring-2 focus:ring-[var(--color-error)]"
|
|
/>
|
|
<span className="text-sm text-[var(--text-primary)]">
|
|
{t('setup_wizard:quality.fields.critical', 'Critical check (failure stops production)')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{errors.form && (
|
|
<div className="p-3 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg text-sm text-[var(--color-error)]">
|
|
{errors.form}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={createTemplateMutation.isPending || !userId}
|
|
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
|
>
|
|
{createTemplateMutation.isPending ? (
|
|
<span className="flex items-center gap-2">
|
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
{t('common:saving', 'Saving...')}
|
|
</span>
|
|
) : (
|
|
t('common:add', 'Add')
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={resetForm}
|
|
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
|
|
>
|
|
{t('common:cancel', 'Cancel')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsAdding(true)}
|
|
className="w-full p-4 border-2 border-dashed border-[var(--border-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-colors group"
|
|
>
|
|
<div className="flex items-center justify-center gap-2 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)]">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
<span className="font-medium">
|
|
{templates.length === 0
|
|
? t('setup_wizard:quality.add_first', 'Add Your First Quality Check')
|
|
: t('setup_wizard:quality.add_another', 'Add Another Quality Check')}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
)}
|
|
|
|
{/* Loading state */}
|
|
{isLoading && templates.length === 0 && (
|
|
<div className="text-center py-8">
|
|
<svg className="animate-spin h-8 w-8 text-[var(--color-primary)] mx-auto" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
|
{t('common:loading', 'Loading...')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Navigation buttons */}
|
|
{!isAdding && onComplete && (
|
|
<div className="flex items-center justify-between gap-4 pt-6 mt-6 border-t border-[var(--border-secondary)]">
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onPrevious}
|
|
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors font-medium"
|
|
>
|
|
← {t('common:previous', 'Anterior')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => onComplete()}
|
|
disabled={canContinue === false}
|
|
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2"
|
|
>
|
|
{t('common:next', 'Continuar →')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|