Add equipment fail feature

This commit is contained in:
Urtzi Alfaro
2026-01-11 17:03:46 +01:00
parent b66bfda100
commit ce4f3aff8c
19 changed files with 2101 additions and 51 deletions

View File

@@ -0,0 +1,335 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CheckCircle, Clock, FileText, HelpCircle, User, Wrench } from 'lucide-react';
import { Modal, ModalHeader, ModalBody, ModalFooter, Button, Input, Textarea, Select, SelectItem, FileUpload, Alert, type SelectOption } from '../../../components/ui';
import { Equipment } from '../../../api/types/equipment';
import { useToast } from '../../../hooks/useToast';
interface MarkAsRepairedModalProps {
isOpen: boolean;
onClose: () => void;
equipment: Equipment;
onMarkAsRepaired: (repairData: {
repairDate: string;
technicianName: string;
repairDescription: string;
partsReplaced: string[];
cost: number;
photos?: File[];
testResults: boolean;
}) => Promise<void>;
isLoading?: boolean;
}
export const MarkAsRepairedModal: React.FC<MarkAsRepairedModalProps> = ({
isOpen,
onClose,
equipment,
onMarkAsRepaired,
isLoading = false
}) => {
const { t } = useTranslation(['equipment', 'common']);
const { showToast } = useToast();
const [repairDate, setRepairDate] = useState<string>(new Date().toISOString().split('T')[0]);
const [technicianName, setTechnicianName] = useState<string>('');
const [repairDescription, setRepairDescription] = useState<string>('');
const [partsReplaced, setPartsReplaced] = useState<string[]>([]);
const [newPart, setNewPart] = useState<string>('');
const [cost, setCost] = useState<number>(0);
const [photos, setPhotos] = useState<File[]>([]);
const [testResults, setTestResults] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!repairDescription.trim()) {
setError(t('errors.repair_description_required') || 'Repair description is required');
return;
}
if (!technicianName.trim()) {
setError(t('errors.technician_required') || 'Technician name is required');
return;
}
try {
await onMarkAsRepaired({
repairDate,
technicianName,
repairDescription,
partsReplaced,
cost,
photos,
testResults
});
showToast({
title: t('success.repair_completed') || 'Repair Completed',
message: t('success.repair_completed_message', { equipment: equipment.name }) || `${equipment.name} has been marked as repaired successfully`,
type: 'success'
});
// Reset form
setRepairDescription('');
setTechnicianName('');
setPartsReplaced([]);
setNewPart('');
setCost(0);
setPhotos([]);
setTestResults(true);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t('errors.repair_failed') || 'Failed to mark as repaired');
showToast({
title: t('errors.repair_failed') || 'Repair Failed',
message: err instanceof Error ? err.message : t('errors.try_again') || 'Please try again',
type: 'error'
});
}
};
const handleAddPart = () => {
if (newPart.trim() && !partsReplaced.includes(newPart.trim())) {
setPartsReplaced([...partsReplaced, newPart.trim()]);
setNewPart('');
}
};
const handleRemovePart = (part: string) => {
setPartsReplaced(partsReplaced.filter(p => p !== part));
};
const handlePhotoUpload = (files: File[]) => {
setPhotos(files);
};
// Calculate downtime
const calculateDowntime = () => {
if (!equipment.lastMaintenance) return 0;
const lastMaintenanceDate = new Date(equipment.lastMaintenance);
const repairDateObj = new Date(repairDate);
const diffHours = Math.abs(repairDateObj.getTime() - lastMaintenanceDate.getTime()) / (1000 * 60 * 60);
return Math.round(diffHours);
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<form onSubmit={handleSubmit}>
<ModalHeader>
<div className="flex items-center gap-2">
<CheckCircle className="text-green-500" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('mark_repaired.title', { equipment: equipment.name }) || `Mark as Repaired: ${equipment.name}`}
</h3>
</div>
</ModalHeader>
<ModalBody>
{error && (
<Alert type="error" className="mb-4">
{error}
</Alert>
)}
<div className="space-y-4">
{/* Equipment Info */}
<div className="bg-[var(--background-secondary)] p-3 rounded-lg">
<h4 className="font-medium text-[var(--text-primary)] mb-2 flex items-center gap-2">
<Wrench className="h-4 w-4" />
{t('equipment_info.title') || 'Equipment Information'}
</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('fields.type') || 'Type'}:</span>
<span className="font-medium text-[var(--text-primary)]">{equipment.type}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('fields.model') || 'Model'}:</span>
<span className="font-medium text-[var(--text-primary)]">{equipment.model || 'N/A'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('fields.location') || 'Location'}:</span>
<span className="font-medium text-[var(--text-primary)]">{equipment.location || 'N/A'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('fields.serial_number') || 'Serial'}:</span>
<span className="font-medium text-[var(--text-primary)]">{equipment.serialNumber || 'N/A'}</span>
</div>
</div>
</div>
{/* Repair Date */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('fields.repair_date') || 'Repair Date'}
</label>
<Input
type="date"
value={repairDate}
onChange={(e) => setRepairDate(e.target.value)}
required
/>
</div>
{/* Technician Name */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('fields.technician_name') || 'Technician Name'}
</label>
<Input
value={technicianName}
onChange={(e) => setTechnicianName(e.target.value)}
placeholder={t('placeholders.technician_name') || 'Enter technician name'}
required
/>
</div>
{/* Repair Description */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('fields.repair_description') || 'Repair Description'}
</label>
<Textarea
value={repairDescription}
onChange={(e) => setRepairDescription(e.target.value)}
placeholder={t('placeholders.repair_description') || 'Describe the repair work performed...'}
rows={4}
required
/>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('hints.repair_description') || 'Include what was fixed, methods used, and any observations'}
</p>
</div>
{/* Parts Replaced */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('fields.parts_replaced') || 'Parts Replaced'}
</label>
<div className="flex gap-2 mb-2">
<Input
value={newPart}
onChange={(e) => setNewPart(e.target.value)}
placeholder={t('placeholders.add_part') || 'Add part name'}
/>
<Button type="button" size="sm" onClick={handleAddPart} disabled={!newPart.trim()}>
{t('actions.add') || 'Add'}
</Button>
</div>
{partsReplaced.length > 0 && (
<div className="flex flex-wrap gap-2">
{partsReplaced.map((part, index) => (
<div key={index} className="bg-[var(--background-tertiary)] px-3 py-1 rounded-full text-sm flex items-center gap-2">
<span>{part}</span>
<button
type="button"
onClick={() => handleRemovePart(part)}
className="text-red-500 hover:text-red-700"
>
×
</button>
</div>
))}
</div>
)}
</div>
{/* Cost */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('fields.cost') || 'Repair Cost (€)'}
</label>
<Input
type="number"
value={cost}
onChange={(e) => setCost(parseFloat(e.target.value) || 0)}
min={0}
step={0.01}
placeholder="0.00"
/>
</div>
{/* Photos */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('fields.photos') || 'Photos (Optional)'}
</label>
<FileUpload
accept="image/*"
multiple
onChange={handlePhotoUpload}
maxFiles={5}
maxSize={5 * 1024 * 1024} // 5MB
/>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('hints.repair_photos') || 'Upload before/after photos of the repair (max 5MB each)'}
</p>
</div>
{/* Test Results */}
<div className="flex items-center gap-3">
<input
type="checkbox"
id="testResults"
checked={testResults}
onChange={(e) => setTestResults(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
required
/>
<label htmlFor="testResults" className="text-sm text-[var(--text-primary)]">
{t('fields.test_results') || 'Equipment tested and operational'}
</label>
<HelpCircle className="h-4 w-4 text-[var(--text-secondary)]" />
</div>
{/* Downtime Summary */}
<div className="bg-[var(--background-tertiary)] p-3 rounded-lg">
<h4 className="font-medium text-[var(--text-primary)] mb-2 flex items-center gap-2">
<Clock className="h-4 w-4" />
{t('downtime_summary.title') || 'Downtime Summary'}
</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('downtime_summary.downtime') || 'Downtime'}:</span>
<span className="font-medium text-[var(--text-primary)]">{calculateDowntime()} {t('downtime_summary.hours') || 'hours'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('downtime_summary.cost') || 'Estimated Cost'}:</span>
<span className="font-medium text-[var(--text-primary)]">{cost.toFixed(2)}</span>
</div>
</div>
</div>
</div>
</ModalBody>
<ModalFooter>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
{t('common:actions.cancel') || 'Cancel'}
</Button>
<Button type="submit" variant="success" disabled={isLoading}>
{isLoading ? (
<>
<span className="animate-spin mr-2">🔄</span>
{t('common:actions.processing') || 'Processing...'}
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
{t('actions.mark_repaired') || 'Mark as Repaired'}
</>
)}
</Button>
</div>
</ModalFooter>
</form>
</Modal>
);
};
// Default export for easier imports
export default MarkAsRepairedModal;

View File

@@ -0,0 +1,275 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Camera, CheckCircle, Clock, FileText, HelpCircle, Mail, Phone, User, Settings } from 'lucide-react';
import { Modal, ModalHeader, ModalBody, ModalFooter, Button, Input, Textarea, Select, SelectItem, FileUpload, Alert, type SelectOption } from '../../../components/ui';
import { Equipment } from '../../../api/types/equipment';
import { useToast } from '../../../hooks/useToast';
interface ReportFailureModalProps {
isOpen: boolean;
onClose: () => void;
equipment: Equipment;
onReportFailure: (failureData: {
failureType: string;
severity: string;
description: string;
photos?: File[];
estimatedImpact: boolean;
}) => Promise<void>;
isLoading?: boolean;
}
export const ReportFailureModal: React.FC<ReportFailureModalProps> = ({
isOpen,
onClose,
equipment,
onReportFailure,
isLoading = false
}) => {
const { t } = useTranslation(['equipment', 'common']);
const { showToast } = useToast();
const [failureType, setFailureType] = useState<string>('mechanical');
const [severity, setSeverity] = useState<string>('high');
const [description, setDescription] = useState<string>('');
const [photos, setPhotos] = useState<File[]>([]);
const [estimatedImpact, setEstimatedImpact] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const failureTypes: SelectOption[] = [
{ value: 'mechanical', label: t('failure_types.mechanical') || 'Mechanical' },
{ value: 'electrical', label: t('failure_types.electrical') || 'Electrical' },
{ value: 'software', label: t('failure_types.software') || 'Software' },
{ value: 'temperature', label: t('failure_types.temperature') || 'Temperature' },
{ value: 'other', label: t('failure_types.other') || 'Other' }
];
const severityOptions: SelectOption[] = [
{ value: 'high', label: t('severity.high') || 'High' },
{ value: 'urgent', label: t('severity.urgent') || 'Urgent' },
{ value: 'medium', label: t('severity.medium') || 'Medium' },
{ value: 'low', label: t('severity.low') || 'Low' }
];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!description.trim()) {
setError(t('errors.description_required') || 'Description is required');
return;
}
try {
await onReportFailure({
failureType,
severity,
description,
photos,
estimatedImpact
});
showToast({
title: t('success.failure_reported') || 'Failure Reported',
message: t('success.failure_reported_message', { equipment: equipment.name }) || `Failure for ${equipment.name} has been reported successfully`,
type: 'success'
});
// Reset form
setDescription('');
setPhotos([]);
setEstimatedImpact(true);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : t('errors.report_failed') || 'Failed to report failure');
showToast({
title: t('errors.report_failed') || 'Report Failed',
message: err instanceof Error ? err.message : t('errors.try_again') || 'Please try again',
type: 'error'
});
}
};
const handlePhotoUpload = (files: File[]) => {
setPhotos(files);
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<form onSubmit={handleSubmit}>
<ModalHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="text-red-500" />
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{t('report_failure.title', { equipment: equipment.name }) || `Report Failure: ${equipment.name}`}
</h3>
</div>
</ModalHeader>
<ModalBody>
{error && (
<Alert type="error" className="mb-4">
{error}
</Alert>
)}
<div className="space-y-4">
{/* Equipment Info */}
<div className="bg-[var(--background-secondary)] p-3 rounded-lg">
<h4 className="font-medium text-[var(--text-primary)] mb-2 flex items-center gap-2">
<Settings className="h-4 w-4" />
{t('equipment_info.title') || 'Equipment Information'}
</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('fields.type') || 'Type'}:</span>
<span className="font-medium text-[var(--text-primary)]">{equipment.type}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('fields.model') || 'Model'}:</span>
<span className="font-medium text-[var(--text-primary)]">{equipment.model || 'N/A'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('fields.location') || 'Location'}:</span>
<span className="font-medium text-[var(--text-primary)]">{equipment.location || 'N/A'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{t('fields.serial_number') || 'Serial'}:</span>
<span className="font-medium text-[var(--text-primary)]">{equipment.serialNumber || 'N/A'}</span>
</div>
</div>
</div>
{/* Failure Type */}
<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">
{t('fields.failure_type') || 'Failure Type'}
</label>
<Select
value={failureType}
onChange={(value) => setFailureType(value as string)}
options={failureTypes}
placeholder={t('common:forms.select_option') || 'Select failure type'}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('fields.severity') || 'Severity'}
</label>
<Select
value={severity}
onChange={(value) => setSeverity(value as string)}
options={severityOptions}
placeholder={t('common:forms.select_option') || 'Select severity'}
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('fields.description') || 'Description'}
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('placeholders.failure_description') || 'Describe the failure in detail...'}
rows={4}
required
/>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('hints.failure_description') || 'Include symptoms, error messages, and any troubleshooting steps attempted'}
</p>
</div>
{/* Photos */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('fields.photos') || 'Photos (Optional)'}
</label>
<FileUpload
accept="image/*"
multiple
onChange={handlePhotoUpload}
maxFiles={5}
maxSize={5 * 1024 * 1024} // 5MB
/>
<p className="text-xs text-[var(--text-secondary)] mt-1">
{t('hints.failure_photos') || 'Upload photos showing the issue (max 5MB each)'}
</p>
</div>
{/* Estimated Impact */}
<div className="flex items-center gap-3">
<input
type="checkbox"
id="estimatedImpact"
checked={estimatedImpact}
onChange={(e) => setEstimatedImpact(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="estimatedImpact" className="text-sm text-[var(--text-primary)]">
{t('fields.estimated_impact') || 'This failure affects production'}
</label>
<HelpCircle className="h-4 w-4 text-[var(--text-secondary)]" />
</div>
{/* Support Contact Info */}
{equipment.support_contact && (
<div className="bg-[var(--background-tertiary)] p-3 rounded-lg">
<h4 className="font-medium text-[var(--text-primary)] mb-2 flex items-center gap-2">
<User className="h-4 w-4" />
{t('support_contact.title') || 'Support Contact'}
</h4>
<div className="space-y-1 text-sm">
{equipment.support_contact.email && (
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-[var(--text-secondary)]" />
<span>{equipment.support_contact.email}</span>
</div>
)}
{equipment.support_contact.phone && (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-[var(--text-secondary)]" />
<span>{equipment.support_contact.phone}</span>
</div>
)}
{equipment.support_contact.company && (
<div className="flex items-center gap-2">
<span className="text-[var(--text-secondary)]">{equipment.support_contact.company}</span>
</div>
)}
</div>
</div>
)}
</div>
</ModalBody>
<ModalFooter>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
{t('common:actions.cancel') || 'Cancel'}
</Button>
<Button type="submit" variant="danger" disabled={isLoading}>
{isLoading ? (
<>
<span className="animate-spin mr-2">🔄</span>
{t('common:actions.processing') || 'Processing...'}
</>
) : (
<>
<AlertTriangle className="h-4 w-4 mr-2" />
{t('actions.report_failure') || 'Report Failure'}
</>
)}
</Button>
</div>
</ModalFooter>
</form>
</Modal>
);
};
// Default export for easier imports
export default ReportFailureModal;

View File

@@ -0,0 +1,143 @@
import React, { useRef, useState } from 'react';
interface FileUploadProps {
accept?: string;
multiple?: boolean;
onChange: (files: File[]) => void;
maxFiles?: number;
maxSize?: number;
children?: React.ReactNode;
className?: string;
}
export const FileUpload: React.FC<FileUploadProps> = ({
accept,
multiple = false,
onChange,
maxFiles = 1,
maxSize,
children,
className = ''
}) => {
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const handleFiles = (files: FileList | null) => {
if (!files) return;
let filesArray = Array.from(files);
// Apply max files constraint
if (multiple && filesArray.length > maxFiles) {
filesArray = filesArray.slice(0, maxFiles);
} else if (!multiple && filesArray.length > 1) {
filesArray = filesArray.slice(0, 1);
}
// Apply max size constraint
if (maxSize) {
filesArray = filesArray.filter(file => file.size <= maxSize);
}
setSelectedFiles(prev => multiple ? [...prev, ...filesArray] : filesArray);
onChange(multiple ? [...selectedFiles, ...filesArray] : filesArray);
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
handleFiles(e.dataTransfer.files);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(e.target.files);
};
const onButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const removeFile = (index: number) => {
const newFiles = selectedFiles.filter((_, i) => i !== index);
setSelectedFiles(newFiles);
onChange(newFiles);
};
return (
<div className={`relative ${className}`}>
<form
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
dragActive
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={onButtonClick}
>
<input
ref={fileInputRef}
type="file"
multiple={multiple}
accept={accept}
onChange={handleChange}
className="hidden"
/>
{children || (
<div>
<p className="text-gray-600 mb-2">
Drag & drop files here, or click to select
</p>
<p className="text-sm text-gray-500">
{accept ? `Accepted: ${accept}` : 'Any file type accepted'}
{maxSize && `, Max size: ${(maxSize / (1024 * 1024)).toFixed(1)}MB`}
</p>
</div>
)}
</form>
{/* Preview selected files */}
{selectedFiles.length > 0 && (
<div className="mt-4 space-y-2">
<h4 className="text-sm font-medium text-gray-700">Selected Files:</h4>
<ul className="space-y-1">
{selectedFiles.map((file, index) => (
<li key={index} className="flex justify-between items-center bg-gray-50 p-2 rounded text-sm">
<span className="truncate max-w-xs">{file.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
className="ml-2 text-red-500 hover:text-red-700"
>
×
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default FileUpload;

View File

@@ -2,6 +2,7 @@
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Textarea } from './Textarea/Textarea';
export { default as FileUpload } from './FileUpload';
export { default as Card, CardHeader, CardBody, CardFooter, CardContent, CardTitle, CardDescription } from './Card';
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
export { default as Table } from './Table';