585 lines
20 KiB
TypeScript
585 lines
20 KiB
TypeScript
|
|
import React, { useState, useCallback } from 'react';
|
|||
|
|
import { useTranslation } from 'react-i18next';
|
|||
|
|
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
|||
|
|
import { Button } from '../../ui/Button';
|
|||
|
|
import { Input } from '../../ui/Input';
|
|||
|
|
import { Textarea } from '../../ui';
|
|||
|
|
import { Badge } from '../../ui/Badge';
|
|||
|
|
import { Modal } from '../../ui/Modal';
|
|||
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../ui/Tabs';
|
|||
|
|
import {
|
|||
|
|
Camera,
|
|||
|
|
CheckCircle,
|
|||
|
|
XCircle,
|
|||
|
|
AlertTriangle,
|
|||
|
|
Upload,
|
|||
|
|
Star,
|
|||
|
|
Target,
|
|||
|
|
FileText,
|
|||
|
|
Clock,
|
|||
|
|
User,
|
|||
|
|
Package
|
|||
|
|
} from 'lucide-react';
|
|||
|
|
import { useCurrentTenant } from '../../../stores/tenant.store';
|
|||
|
|
|
|||
|
|
export interface InspectionCriteria {
|
|||
|
|
id: string;
|
|||
|
|
category: string;
|
|||
|
|
name: string;
|
|||
|
|
description: string;
|
|||
|
|
type: 'visual' | 'measurement' | 'taste' | 'texture' | 'temperature';
|
|||
|
|
required: boolean;
|
|||
|
|
weight: number;
|
|||
|
|
acceptableCriteria: string;
|
|||
|
|
minValue?: number;
|
|||
|
|
maxValue?: number;
|
|||
|
|
unit?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface InspectionResult {
|
|||
|
|
criteriaId: string;
|
|||
|
|
value: number | string | boolean;
|
|||
|
|
score: number;
|
|||
|
|
notes?: string;
|
|||
|
|
photos?: File[];
|
|||
|
|
pass: boolean;
|
|||
|
|
timestamp: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface QualityInspectionData {
|
|||
|
|
batchId: string;
|
|||
|
|
productName: string;
|
|||
|
|
inspectionType: string;
|
|||
|
|
inspector: string;
|
|||
|
|
startTime: string;
|
|||
|
|
criteria: InspectionCriteria[];
|
|||
|
|
results: InspectionResult[];
|
|||
|
|
overallScore: number;
|
|||
|
|
overallPass: boolean;
|
|||
|
|
finalNotes: string;
|
|||
|
|
photos: File[];
|
|||
|
|
correctiveActions: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface QualityInspectionProps {
|
|||
|
|
className?: string;
|
|||
|
|
batchId?: string;
|
|||
|
|
productName?: string;
|
|||
|
|
inspectionType?: string;
|
|||
|
|
criteria?: InspectionCriteria[];
|
|||
|
|
onComplete?: (data: QualityInspectionData) => void;
|
|||
|
|
onCancel?: () => void;
|
|||
|
|
onSaveDraft?: (data: Partial<QualityInspectionData>) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const DEFAULT_CRITERIA: InspectionCriteria[] = [
|
|||
|
|
{
|
|||
|
|
id: 'color_uniformity',
|
|||
|
|
category: 'Visual',
|
|||
|
|
name: 'Color Uniformity',
|
|||
|
|
description: 'Evaluate the consistency of color across the product',
|
|||
|
|
type: 'visual',
|
|||
|
|
required: true,
|
|||
|
|
weight: 15,
|
|||
|
|
acceptableCriteria: 'Score 7 or higher',
|
|||
|
|
minValue: 1,
|
|||
|
|
maxValue: 10
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'shape_integrity',
|
|||
|
|
category: 'Visual',
|
|||
|
|
name: 'Shape Integrity',
|
|||
|
|
description: 'Check if the product maintains its intended shape',
|
|||
|
|
type: 'visual',
|
|||
|
|
required: true,
|
|||
|
|
weight: 20,
|
|||
|
|
acceptableCriteria: 'Score 7 or higher',
|
|||
|
|
minValue: 1,
|
|||
|
|
maxValue: 10
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'surface_texture',
|
|||
|
|
category: 'Texture',
|
|||
|
|
name: 'Surface Texture',
|
|||
|
|
description: 'Evaluate surface texture quality',
|
|||
|
|
type: 'texture',
|
|||
|
|
required: true,
|
|||
|
|
weight: 15,
|
|||
|
|
acceptableCriteria: 'Score 7 or higher',
|
|||
|
|
minValue: 1,
|
|||
|
|
maxValue: 10
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'weight_accuracy',
|
|||
|
|
category: 'Measurement',
|
|||
|
|
name: 'Weight Accuracy',
|
|||
|
|
description: 'Measure actual weight vs target weight',
|
|||
|
|
type: 'measurement',
|
|||
|
|
required: true,
|
|||
|
|
weight: 20,
|
|||
|
|
acceptableCriteria: 'Within <20>5% of target',
|
|||
|
|
unit: 'g'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'internal_texture',
|
|||
|
|
category: 'Texture',
|
|||
|
|
name: 'Internal Texture',
|
|||
|
|
description: 'Evaluate crumb structure and texture',
|
|||
|
|
type: 'texture',
|
|||
|
|
required: false,
|
|||
|
|
weight: 15,
|
|||
|
|
acceptableCriteria: 'Score 7 or higher',
|
|||
|
|
minValue: 1,
|
|||
|
|
maxValue: 10
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'taste_quality',
|
|||
|
|
category: 'Taste',
|
|||
|
|
name: 'Taste Quality',
|
|||
|
|
description: 'Overall flavor and taste assessment',
|
|||
|
|
type: 'taste',
|
|||
|
|
required: false,
|
|||
|
|
weight: 15,
|
|||
|
|
acceptableCriteria: 'Score 7 or higher',
|
|||
|
|
minValue: 1,
|
|||
|
|
maxValue: 10
|
|||
|
|
}
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const QualityInspection: React.FC<QualityInspectionProps> = ({
|
|||
|
|
className,
|
|||
|
|
batchId = 'PROD-2024-0123-001',
|
|||
|
|
productName = 'Pan de Molde Integral',
|
|||
|
|
inspectionType = 'Final Quality Check',
|
|||
|
|
criteria = DEFAULT_CRITERIA,
|
|||
|
|
onComplete,
|
|||
|
|
onCancel,
|
|||
|
|
onSaveDraft
|
|||
|
|
}) => {
|
|||
|
|
const { t } = useTranslation();
|
|||
|
|
const currentTenant = useCurrentTenant();
|
|||
|
|
const [activeTab, setActiveTab] = useState('inspection');
|
|||
|
|
|
|||
|
|
const [inspectionData, setInspectionData] = useState<Partial<QualityInspectionData>>({
|
|||
|
|
batchId,
|
|||
|
|
productName,
|
|||
|
|
inspectionType,
|
|||
|
|
inspector: currentTenant?.name || 'Inspector',
|
|||
|
|
startTime: new Date().toISOString(),
|
|||
|
|
criteria,
|
|||
|
|
results: [],
|
|||
|
|
finalNotes: '',
|
|||
|
|
photos: [],
|
|||
|
|
correctiveActions: []
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const [currentCriteriaIndex, setCurrentCriteriaIndex] = useState(0);
|
|||
|
|
const [showPhotoModal, setShowPhotoModal] = useState(false);
|
|||
|
|
const [tempPhotos, setTempPhotos] = useState<File[]>([]);
|
|||
|
|
|
|||
|
|
const updateResult = useCallback((criteriaId: string, updates: Partial<InspectionResult>) => {
|
|||
|
|
setInspectionData(prev => {
|
|||
|
|
const existingResults = prev.results || [];
|
|||
|
|
const existingIndex = existingResults.findIndex(r => r.criteriaId === criteriaId);
|
|||
|
|
|
|||
|
|
let newResults;
|
|||
|
|
if (existingIndex >= 0) {
|
|||
|
|
newResults = [...existingResults];
|
|||
|
|
newResults[existingIndex] = { ...newResults[existingIndex], ...updates };
|
|||
|
|
} else {
|
|||
|
|
newResults = [...existingResults, {
|
|||
|
|
criteriaId,
|
|||
|
|
value: '',
|
|||
|
|
score: 0,
|
|||
|
|
pass: false,
|
|||
|
|
timestamp: new Date().toISOString(),
|
|||
|
|
...updates
|
|||
|
|
}];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { ...prev, results: newResults };
|
|||
|
|
});
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const getCriteriaResult = useCallback((criteriaId: string): InspectionResult | undefined => {
|
|||
|
|
return inspectionData.results?.find(r => r.criteriaId === criteriaId);
|
|||
|
|
}, [inspectionData.results]);
|
|||
|
|
|
|||
|
|
const calculateOverallScore = useCallback((): number => {
|
|||
|
|
if (!inspectionData.results || inspectionData.results.length === 0) return 0;
|
|||
|
|
|
|||
|
|
const totalWeight = criteria.reduce((sum, c) => sum + c.weight, 0);
|
|||
|
|
const weightedScore = inspectionData.results.reduce((sum, result) => {
|
|||
|
|
const criterion = criteria.find(c => c.id === result.criteriaId);
|
|||
|
|
return sum + (result.score * (criterion?.weight || 0));
|
|||
|
|
}, 0);
|
|||
|
|
|
|||
|
|
return totalWeight > 0 ? weightedScore / totalWeight : 0;
|
|||
|
|
}, [inspectionData.results, criteria]);
|
|||
|
|
|
|||
|
|
const isInspectionComplete = useCallback((): boolean => {
|
|||
|
|
const requiredCriteria = criteria.filter(c => c.required);
|
|||
|
|
const completedRequired = requiredCriteria.filter(c =>
|
|||
|
|
inspectionData.results?.some(r => r.criteriaId === c.id)
|
|||
|
|
);
|
|||
|
|
return completedRequired.length === requiredCriteria.length;
|
|||
|
|
}, [criteria, inspectionData.results]);
|
|||
|
|
|
|||
|
|
const handlePhotoUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|||
|
|
const files = Array.from(event.target.files || []);
|
|||
|
|
setTempPhotos(prev => [...prev, ...files]);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const handleComplete = useCallback(() => {
|
|||
|
|
const overallScore = calculateOverallScore();
|
|||
|
|
const overallPass = overallScore >= 7.0; // Configurable threshold
|
|||
|
|
|
|||
|
|
const completedData: QualityInspectionData = {
|
|||
|
|
...inspectionData as QualityInspectionData,
|
|||
|
|
overallScore,
|
|||
|
|
overallPass,
|
|||
|
|
photos: tempPhotos
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onComplete?.(completedData);
|
|||
|
|
}, [inspectionData, calculateOverallScore, tempPhotos, onComplete]);
|
|||
|
|
|
|||
|
|
const currentCriteria = criteria[currentCriteriaIndex];
|
|||
|
|
const currentResult = currentCriteria ? getCriteriaResult(currentCriteria.id) : undefined;
|
|||
|
|
const overallScore = calculateOverallScore();
|
|||
|
|
const isComplete = isInspectionComplete();
|
|||
|
|
|
|||
|
|
const renderCriteriaInput = (criterion: InspectionCriteria) => {
|
|||
|
|
const result = getCriteriaResult(criterion.id);
|
|||
|
|
|
|||
|
|
if (criterion.type === 'measurement') {
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<Input
|
|||
|
|
type="number"
|
|||
|
|
placeholder={`Enter ${criterion.name.toLowerCase()}`}
|
|||
|
|
value={result?.value as string || ''}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const value = parseFloat(e.target.value);
|
|||
|
|
const score = !isNaN(value) ? Math.min(10, Math.max(1, 8)) : 0; // Simplified scoring
|
|||
|
|
updateResult(criterion.id, {
|
|||
|
|
value: e.target.value,
|
|||
|
|
score,
|
|||
|
|
pass: score >= 7
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
{criterion.unit && (
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|||
|
|
Unit: {criterion.unit}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// For visual, texture, taste types - use 1-10 scale
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div className="grid grid-cols-5 gap-2">
|
|||
|
|
{[...Array(10)].map((_, i) => {
|
|||
|
|
const score = i + 1;
|
|||
|
|
const isSelected = result?.score === score;
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={score}
|
|||
|
|
className={`p-3 rounded-lg border-2 transition-all ${
|
|||
|
|
isSelected
|
|||
|
|
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10'
|
|||
|
|
: 'border-[var(--border-primary)] hover:border-[var(--color-primary)]/50'
|
|||
|
|
}`}
|
|||
|
|
onClick={() => {
|
|||
|
|
updateResult(criterion.id, {
|
|||
|
|
value: score,
|
|||
|
|
score,
|
|||
|
|
pass: score >= 7
|
|||
|
|
});
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<div className="text-center">
|
|||
|
|
<div className="font-bold text-[var(--text-primary)]">{score}</div>
|
|||
|
|
<div className="flex justify-center">
|
|||
|
|
<Star className={`w-4 h-4 ${isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--text-tertiary)]'}`} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|||
|
|
<p>1-3: Poor | 4-6: Fair | 7-8: Good | 9-10: Excellent</p>
|
|||
|
|
<p className="font-medium mt-1">Acceptable: {criterion.acceptableCriteria}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Card className={className}>
|
|||
|
|
<CardHeader>
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
|||
|
|
{t('quality.inspection.title', 'Quality Inspection')}
|
|||
|
|
</h3>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|||
|
|
{productName} " {batchId} " {inspectionType}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center space-x-2">
|
|||
|
|
<Badge variant={isComplete ? 'success' : 'warning'}>
|
|||
|
|
{isComplete ? t('quality.status.complete', 'Complete') : t('quality.status.in_progress', 'In Progress')}
|
|||
|
|
</Badge>
|
|||
|
|
{overallScore > 0 && (
|
|||
|
|
<Badge variant={overallScore >= 7 ? 'success' : overallScore >= 5 ? 'warning' : 'error'}>
|
|||
|
|
{overallScore.toFixed(1)}/10
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</CardHeader>
|
|||
|
|
|
|||
|
|
<CardBody className="space-y-6">
|
|||
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|||
|
|
<TabsList className="grid w-full grid-cols-3">
|
|||
|
|
<TabsTrigger value="inspection">
|
|||
|
|
{t('quality.tabs.inspection', 'Inspection')}
|
|||
|
|
</TabsTrigger>
|
|||
|
|
<TabsTrigger value="photos">
|
|||
|
|
{t('quality.tabs.photos', 'Photos')}
|
|||
|
|
</TabsTrigger>
|
|||
|
|
<TabsTrigger value="summary">
|
|||
|
|
{t('quality.tabs.summary', 'Summary')}
|
|||
|
|
</TabsTrigger>
|
|||
|
|
</TabsList>
|
|||
|
|
|
|||
|
|
<TabsContent value="inspection" className="space-y-6">
|
|||
|
|
{/* Progress Indicator */}
|
|||
|
|
<div className="grid grid-cols-6 gap-2">
|
|||
|
|
{criteria.map((criterion, index) => {
|
|||
|
|
const result = getCriteriaResult(criterion.id);
|
|||
|
|
const isCompleted = !!result;
|
|||
|
|
const isCurrent = index === currentCriteriaIndex;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={criterion.id}
|
|||
|
|
className={`p-2 rounded text-xs transition-all ${
|
|||
|
|
isCurrent
|
|||
|
|
? 'bg-[var(--color-primary)] text-white'
|
|||
|
|
: isCompleted
|
|||
|
|
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
|||
|
|
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
|
|||
|
|
}`}
|
|||
|
|
onClick={() => setCurrentCriteriaIndex(index)}
|
|||
|
|
>
|
|||
|
|
{index + 1}
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Current Criteria */}
|
|||
|
|
{currentCriteria && (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
|
|||
|
|
<div className="flex items-center justify-between mb-2">
|
|||
|
|
<h4 className="font-semibold text-[var(--text-primary)]">
|
|||
|
|
{currentCriteria.name}
|
|||
|
|
</h4>
|
|||
|
|
<Badge variant={currentCriteria.required ? 'error' : 'default'}>
|
|||
|
|
{currentCriteria.required ? t('quality.required', 'Required') : t('quality.optional', 'Optional')}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)] mb-3">
|
|||
|
|
{currentCriteria.description}
|
|||
|
|
</p>
|
|||
|
|
<div className="flex items-center space-x-4 text-xs text-[var(--text-tertiary)]">
|
|||
|
|
<span>Weight: {currentCriteria.weight}%</span>
|
|||
|
|
<span>Category: {currentCriteria.category}</span>
|
|||
|
|
<span>Type: {currentCriteria.type}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Input Section */}
|
|||
|
|
{renderCriteriaInput(currentCriteria)}
|
|||
|
|
|
|||
|
|
{/* Notes Section */}
|
|||
|
|
<Textarea
|
|||
|
|
placeholder={t('quality.inspection.notes_placeholder', 'Add notes for this criteria (optional)...')}
|
|||
|
|
value={currentResult?.notes || ''}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
updateResult(currentCriteria.id, { notes: e.target.value });
|
|||
|
|
}}
|
|||
|
|
rows={3}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{/* Navigation */}
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => setCurrentCriteriaIndex(Math.max(0, currentCriteriaIndex - 1))}
|
|||
|
|
disabled={currentCriteriaIndex === 0}
|
|||
|
|
>
|
|||
|
|
{t('common.previous', 'Previous')}
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
variant="primary"
|
|||
|
|
onClick={() => setCurrentCriteriaIndex(Math.min(criteria.length - 1, currentCriteriaIndex + 1))}
|
|||
|
|
disabled={currentCriteriaIndex === criteria.length - 1}
|
|||
|
|
>
|
|||
|
|
{t('common.next', 'Next')}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</TabsContent>
|
|||
|
|
|
|||
|
|
<TabsContent value="photos" className="space-y-4">
|
|||
|
|
<div className="text-center py-8">
|
|||
|
|
<Camera className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
|||
|
|
<p className="text-[var(--text-secondary)] mb-4">
|
|||
|
|
{t('quality.photos.description', 'Add photos to document quality issues or evidence')}
|
|||
|
|
</p>
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => setShowPhotoModal(true)}
|
|||
|
|
>
|
|||
|
|
<Upload className="w-4 h-4 mr-2" />
|
|||
|
|
{t('quality.photos.upload', 'Upload Photos')}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{tempPhotos.length > 0 && (
|
|||
|
|
<div className="grid grid-cols-3 gap-4">
|
|||
|
|
{tempPhotos.map((photo, index) => (
|
|||
|
|
<div key={index} className="relative">
|
|||
|
|
<img
|
|||
|
|
src={URL.createObjectURL(photo)}
|
|||
|
|
alt={`Quality photo ${index + 1}`}
|
|||
|
|
className="w-full h-32 object-cover rounded-lg"
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
className="absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center"
|
|||
|
|
onClick={() => setTempPhotos(prev => prev.filter((_, i) => i !== index))}
|
|||
|
|
>
|
|||
|
|
<EFBFBD>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</TabsContent>
|
|||
|
|
|
|||
|
|
<TabsContent value="summary" className="space-y-6">
|
|||
|
|
{/* Overall Score */}
|
|||
|
|
<div className="text-center py-6 bg-[var(--bg-secondary)] rounded-lg">
|
|||
|
|
<div className="text-4xl font-bold text-[var(--text-primary)] mb-2">
|
|||
|
|
{overallScore.toFixed(1)}/10
|
|||
|
|
</div>
|
|||
|
|
<div className="text-lg text-[var(--text-secondary)]">
|
|||
|
|
{t('quality.summary.overall_score', 'Overall Quality Score')}
|
|||
|
|
</div>
|
|||
|
|
<Badge
|
|||
|
|
variant={overallScore >= 7 ? 'success' : overallScore >= 5 ? 'warning' : 'error'}
|
|||
|
|
className="mt-2"
|
|||
|
|
>
|
|||
|
|
{overallScore >= 7 ? t('quality.status.passed', 'PASSED') : t('quality.status.failed', 'FAILED')}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Results Summary */}
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<h4 className="font-medium text-[var(--text-primary)]">
|
|||
|
|
{t('quality.summary.results', 'Inspection Results')}
|
|||
|
|
</h4>
|
|||
|
|
{criteria.map((criterion) => {
|
|||
|
|
const result = getCriteriaResult(criterion.id);
|
|||
|
|
if (!result) return null;
|
|||
|
|
|
|||
|
|
const StatusIcon = result.pass ? CheckCircle : XCircle;
|
|||
|
|
return (
|
|||
|
|
<div key={criterion.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
|
|||
|
|
<div className="flex items-center space-x-3">
|
|||
|
|
<StatusIcon className={`w-5 h-5 ${result.pass ? 'text-green-500' : 'text-red-500'}`} />
|
|||
|
|
<div>
|
|||
|
|
<div className="font-medium text-[var(--text-primary)]">{criterion.name}</div>
|
|||
|
|
<div className="text-sm text-[var(--text-secondary)]">{criterion.category}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="text-right">
|
|||
|
|
<div className="font-bold text-[var(--text-primary)]">{result.score}/10</div>
|
|||
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|||
|
|
Weight: {criterion.weight}%
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Final Notes */}
|
|||
|
|
<Textarea
|
|||
|
|
placeholder={t('quality.summary.final_notes', 'Add final notes and recommendations...')}
|
|||
|
|
value={inspectionData.finalNotes || ''}
|
|||
|
|
onChange={(e) => setInspectionData(prev => ({ ...prev, finalNotes: e.target.value }))}
|
|||
|
|
rows={4}
|
|||
|
|
/>
|
|||
|
|
</TabsContent>
|
|||
|
|
</Tabs>
|
|||
|
|
|
|||
|
|
{/* Action Buttons */}
|
|||
|
|
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
|
|||
|
|
<div className="flex space-x-3">
|
|||
|
|
<Button variant="outline" onClick={onCancel}>
|
|||
|
|
{t('common.cancel', 'Cancel')}
|
|||
|
|
</Button>
|
|||
|
|
<Button variant="secondary" onClick={() => onSaveDraft?.(inspectionData)}>
|
|||
|
|
{t('quality.actions.save_draft', 'Save Draft')}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
<Button
|
|||
|
|
variant="primary"
|
|||
|
|
onClick={handleComplete}
|
|||
|
|
disabled={!isComplete}
|
|||
|
|
>
|
|||
|
|
{t('quality.actions.complete_inspection', 'Complete Inspection')}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Photo Upload Modal */}
|
|||
|
|
<Modal
|
|||
|
|
isOpen={showPhotoModal}
|
|||
|
|
onClose={() => setShowPhotoModal(false)}
|
|||
|
|
title={t('quality.photos.upload_title', 'Upload Quality Photos')}
|
|||
|
|
>
|
|||
|
|
<div className="p-6">
|
|||
|
|
<input
|
|||
|
|
type="file"
|
|||
|
|
accept="image/*"
|
|||
|
|
multiple
|
|||
|
|
onChange={handlePhotoUpload}
|
|||
|
|
className="w-full p-4 border-2 border-dashed border-[var(--border-primary)] rounded-lg"
|
|||
|
|
/>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)] mt-2">
|
|||
|
|
{t('quality.photos.upload_help', 'Select multiple images to upload')}
|
|||
|
|
</p>
|
|||
|
|
<div className="flex justify-end mt-4">
|
|||
|
|
<Button onClick={() => setShowPhotoModal(false)}>
|
|||
|
|
{t('common.done', 'Done')}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</Modal>
|
|||
|
|
</CardBody>
|
|||
|
|
</Card>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default QualityInspection;
|