Add equipment fail feature
This commit is contained in:
@@ -28,7 +28,7 @@ class EquipmentService {
|
||||
model: response.model || '',
|
||||
serialNumber: response.serial_number || '',
|
||||
location: response.location || '',
|
||||
status: response.status,
|
||||
status: response.status.toLowerCase() as Equipment['status'],
|
||||
installDate: response.install_date || new Date().toISOString().split('T')[0],
|
||||
lastMaintenance: response.last_maintenance_date || new Date().toISOString().split('T')[0],
|
||||
nextMaintenance: response.next_maintenance_date || new Date().toISOString().split('T')[0],
|
||||
@@ -52,6 +52,7 @@ class EquipmentService {
|
||||
weight: response.weight_kg || 0
|
||||
},
|
||||
is_active: response.is_active,
|
||||
support_contact: response.support_contact || undefined,
|
||||
created_at: response.created_at,
|
||||
updated_at: response.updated_at
|
||||
};
|
||||
@@ -80,7 +81,8 @@ class EquipmentService {
|
||||
weight_kg: equipment.specifications?.weight,
|
||||
current_temperature: equipment.temperature,
|
||||
target_temperature: equipment.targetTemperature,
|
||||
is_active: equipment.is_active
|
||||
is_active: equipment.is_active,
|
||||
support_contact: equipment.support_contact
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,6 +204,78 @@ class EquipmentService {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report equipment failure
|
||||
*/
|
||||
async reportEquipmentFailure(
|
||||
tenantId: string,
|
||||
equipmentId: string,
|
||||
failureData: {
|
||||
failureType: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
photos?: File[];
|
||||
estimatedImpact: boolean;
|
||||
}
|
||||
): Promise<Equipment> {
|
||||
const apiData = {
|
||||
failureType: failureData.failureType,
|
||||
severity: failureData.severity,
|
||||
description: failureData.description,
|
||||
estimatedImpact: failureData.estimatedImpact,
|
||||
// Note: Photos would be handled separately in a real implementation
|
||||
// For now, we'll just send the metadata
|
||||
photos: failureData.photos ? failureData.photos.map(p => p.name) : []
|
||||
};
|
||||
|
||||
const data: EquipmentResponse = await apiClient.post(
|
||||
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}/report-failure`,
|
||||
apiData,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
return this.convertToEquipment(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark equipment as repaired
|
||||
*/
|
||||
async markEquipmentAsRepaired(
|
||||
tenantId: string,
|
||||
equipmentId: string,
|
||||
repairData: {
|
||||
repairDate: string;
|
||||
technicianName: string;
|
||||
repairDescription: string;
|
||||
partsReplaced: string[];
|
||||
cost: number;
|
||||
photos?: File[];
|
||||
testResults: boolean;
|
||||
}
|
||||
): Promise<Equipment> {
|
||||
const apiData = {
|
||||
repairDate: repairData.repairDate,
|
||||
technicianName: repairData.technicianName,
|
||||
repairDescription: repairData.repairDescription,
|
||||
partsReplaced: repairData.partsReplaced,
|
||||
cost: repairData.cost,
|
||||
testResults: repairData.testResults,
|
||||
// Note: Photos would be handled separately in a real implementation
|
||||
// For now, we'll just send the metadata
|
||||
photos: repairData.photos ? repairData.photos.map(p => p.name) : []
|
||||
};
|
||||
|
||||
const data: EquipmentResponse = await apiClient.post(
|
||||
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}/mark-repaired`,
|
||||
apiData,
|
||||
{
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
}
|
||||
);
|
||||
return this.convertToEquipment(data);
|
||||
}
|
||||
}
|
||||
|
||||
export const equipmentService = new EquipmentService();
|
||||
|
||||
@@ -53,6 +53,13 @@ export interface Equipment {
|
||||
maintenanceHistory: MaintenanceHistory[];
|
||||
specifications: EquipmentSpecifications;
|
||||
is_active?: boolean;
|
||||
support_contact?: {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
contract_number?: string;
|
||||
response_time_sla?: number;
|
||||
};
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -81,6 +88,13 @@ export interface EquipmentCreate {
|
||||
current_temperature?: number;
|
||||
target_temperature?: number;
|
||||
notes?: string;
|
||||
support_contact?: {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
contract_number?: string;
|
||||
response_time_sla?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EquipmentUpdate {
|
||||
@@ -129,6 +143,13 @@ export interface EquipmentResponse {
|
||||
target_temperature: number | null;
|
||||
is_active: boolean;
|
||||
notes: string | null;
|
||||
support_contact: {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
contract_number?: string;
|
||||
response_time_sla?: number;
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -349,64 +349,55 @@ export function isRecommendation(event: EventResponse | Event): event is Recomme
|
||||
// ============================================================
|
||||
|
||||
export function getPriorityColor(level: PriorityLevel | string): string {
|
||||
switch (level) {
|
||||
case PriorityLevel.CRITICAL:
|
||||
case 'critical':
|
||||
return 'var(--color-error)';
|
||||
case PriorityLevel.IMPORTANT:
|
||||
case 'important':
|
||||
return 'var(--color-warning)';
|
||||
case PriorityLevel.STANDARD:
|
||||
case 'standard':
|
||||
return 'var(--color-info)';
|
||||
case PriorityLevel.INFO:
|
||||
case 'info':
|
||||
return 'var(--color-success)';
|
||||
default:
|
||||
return 'var(--color-info)';
|
||||
const levelValue = String(level);
|
||||
|
||||
if (levelValue === PriorityLevel.CRITICAL || levelValue === 'critical') {
|
||||
return 'var(--color-error)';
|
||||
} else if (levelValue === PriorityLevel.IMPORTANT || levelValue === 'important') {
|
||||
return 'var(--color-warning)';
|
||||
} else if (levelValue === PriorityLevel.STANDARD || levelValue === 'standard') {
|
||||
return 'var(--color-info)';
|
||||
} else if (levelValue === PriorityLevel.INFO || levelValue === 'info') {
|
||||
return 'var(--color-success)';
|
||||
} else {
|
||||
return 'var(--color-info)';
|
||||
}
|
||||
}
|
||||
|
||||
export function getPriorityIcon(level: PriorityLevel | string): string {
|
||||
switch (level) {
|
||||
case PriorityLevel.CRITICAL:
|
||||
case 'critical':
|
||||
return 'alert-triangle';
|
||||
case PriorityLevel.IMPORTANT:
|
||||
case 'important':
|
||||
return 'alert-circle';
|
||||
case PriorityLevel.STANDARD:
|
||||
case 'standard':
|
||||
return 'info';
|
||||
case PriorityLevel.INFO:
|
||||
case 'info':
|
||||
return 'check-circle';
|
||||
default:
|
||||
return 'info';
|
||||
const levelValue = String(level);
|
||||
|
||||
if (levelValue === PriorityLevel.CRITICAL || levelValue === 'critical') {
|
||||
return 'alert-triangle';
|
||||
} else if (levelValue === PriorityLevel.IMPORTANT || levelValue === 'important') {
|
||||
return 'alert-circle';
|
||||
} else if (levelValue === PriorityLevel.STANDARD || levelValue === 'standard') {
|
||||
return 'info';
|
||||
} else if (levelValue === PriorityLevel.INFO || levelValue === 'info') {
|
||||
return 'check-circle';
|
||||
} else {
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
export function getTypeClassBadgeVariant(
|
||||
typeClass: AlertTypeClass | string
|
||||
): 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'outline' {
|
||||
switch (typeClass) {
|
||||
case AlertTypeClass.ACTION_NEEDED:
|
||||
case 'action_needed':
|
||||
return 'error';
|
||||
case AlertTypeClass.PREVENTED_ISSUE:
|
||||
case 'prevented_issue':
|
||||
return 'success';
|
||||
case AlertTypeClass.TREND_WARNING:
|
||||
case 'trend_warning':
|
||||
return 'warning';
|
||||
case AlertTypeClass.ESCALATION:
|
||||
case 'escalation':
|
||||
return 'error';
|
||||
case AlertTypeClass.INFORMATION:
|
||||
case 'information':
|
||||
return 'info';
|
||||
default:
|
||||
return 'default';
|
||||
// Convert to string and compare with known values
|
||||
const typeValue = String(typeClass);
|
||||
|
||||
if (typeValue === AlertTypeClass.ACTION_NEEDED || typeValue === 'action_needed') {
|
||||
return 'error';
|
||||
} else if (typeValue === AlertTypeClass.PREVENTED_ISSUE || typeValue === 'prevented_issue') {
|
||||
return 'success';
|
||||
} else if (typeValue === AlertTypeClass.TREND_WARNING || typeValue === 'trend_warning') {
|
||||
return 'warning';
|
||||
} else if (typeValue === AlertTypeClass.ESCALATION || typeValue === 'escalation') {
|
||||
return 'error';
|
||||
} else if (typeValue === AlertTypeClass.INFORMATION || typeValue === 'information') {
|
||||
return 'info';
|
||||
} else {
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
335
frontend/src/components/domain/equipment/MarkAsRepairedModal.tsx
Normal file
335
frontend/src/components/domain/equipment/MarkAsRepairedModal.tsx
Normal 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;
|
||||
275
frontend/src/components/domain/equipment/ReportFailureModal.tsx
Normal file
275
frontend/src/components/domain/equipment/ReportFailureModal.tsx
Normal 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;
|
||||
143
frontend/src/components/ui/FileUpload.tsx
Normal file
143
frontend/src/components/ui/FileUpload.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
"duration": "Duration (hours)",
|
||||
"parts_needed": "Parts Needed",
|
||||
"description": "Description",
|
||||
"failure_type": "Failure Type",
|
||||
"severity": "Severity",
|
||||
"photos": "Photos",
|
||||
"estimated_impact": "Production Impact",
|
||||
"specifications": {
|
||||
"power": "Power",
|
||||
"capacity": "Capacity",
|
||||
@@ -69,6 +73,8 @@
|
||||
"acknowledge_alert": "Acknowledge Alert",
|
||||
"view_details": "View Details",
|
||||
"view_history": "View History",
|
||||
"report_failure": "Report Failure",
|
||||
"mark_repaired": "Mark as Repaired",
|
||||
"close": "Close",
|
||||
"cost": "Cost",
|
||||
"edit": "Edit"
|
||||
@@ -148,5 +154,34 @@
|
||||
"critical": "Critical",
|
||||
"warning": "Warning",
|
||||
"info": "Information"
|
||||
},
|
||||
"report_failure": {
|
||||
"title": "Report Equipment Failure",
|
||||
"equipment_info": {
|
||||
"title": "Equipment Information"
|
||||
}
|
||||
},
|
||||
"failure_types": {
|
||||
"mechanical": "Mechanical",
|
||||
"electrical": "Electrical",
|
||||
"software": "Software",
|
||||
"temperature": "Temperature",
|
||||
"other": "Other"
|
||||
},
|
||||
"severity": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"urgent": "Urgent"
|
||||
},
|
||||
"hints": {
|
||||
"failure_description": "Describe the observed problem in detail",
|
||||
"failure_photos": "Attach photos of the equipment or issue to facilitate diagnosis"
|
||||
},
|
||||
"mark_repaired": {
|
||||
"title": "Mark Equipment as Repaired",
|
||||
"repair_info": {
|
||||
"title": "Repair Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
"duration": "Duración (horas)",
|
||||
"parts_needed": "Repuestos Necesarios",
|
||||
"description": "Descripción",
|
||||
"failure_type": "Tipo de Fallo",
|
||||
"severity": "Gravedad",
|
||||
"photos": "Fotos",
|
||||
"estimated_impact": "Impacto en la Producción",
|
||||
"specifications": {
|
||||
"power": "Potencia",
|
||||
"capacity": "Capacidad",
|
||||
@@ -69,6 +73,8 @@
|
||||
"acknowledge_alert": "Reconocer Alerta",
|
||||
"view_details": "Ver Detalles",
|
||||
"view_history": "Ver Historial",
|
||||
"report_failure": "Reportar Fallo",
|
||||
"mark_repaired": "Marcar Reparado",
|
||||
"close": "Cerrar",
|
||||
"cost": "Costo",
|
||||
"edit": "Editar"
|
||||
@@ -148,5 +154,34 @@
|
||||
"critical": "Crítico",
|
||||
"warning": "Advertencia",
|
||||
"info": "Información"
|
||||
},
|
||||
"report_failure": {
|
||||
"title": "Reportar Fallo de Equipo",
|
||||
"equipment_info": {
|
||||
"title": "Información del Equipo"
|
||||
}
|
||||
},
|
||||
"failure_types": {
|
||||
"mechanical": "Mecánico",
|
||||
"electrical": "Eléctrico",
|
||||
"software": "Software",
|
||||
"temperature": "Temperatura",
|
||||
"other": "Otro"
|
||||
},
|
||||
"severity": {
|
||||
"low": "Baja",
|
||||
"medium": "Media",
|
||||
"high": "Alta",
|
||||
"urgent": "Urgente"
|
||||
},
|
||||
"hints": {
|
||||
"failure_description": "Describe detalladamente el problema observado",
|
||||
"failure_photos": "Adjunta fotos del equipo o problema para facilitar el diagnóstico"
|
||||
},
|
||||
"mark_repaired": {
|
||||
"title": "Marcar Equipo como Reparado",
|
||||
"repair_info": {
|
||||
"title": "Información de la Reparación"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
"duration": "Iraupena (orduak)",
|
||||
"parts_needed": "Behar diren piezak",
|
||||
"description": "Deskribapena",
|
||||
"failure_type": "Akats mota",
|
||||
"severity": "Larritasuna",
|
||||
"photos": "Argazkiak",
|
||||
"estimated_impact": "Ekoizpenaren gaineko eragina",
|
||||
"specifications": {
|
||||
"power": "Potentzia",
|
||||
"capacity": "Edukiera",
|
||||
@@ -69,6 +73,8 @@
|
||||
"acknowledge_alert": "Berretsi alerta",
|
||||
"view_details": "Ikusi xehetasunak",
|
||||
"view_history": "Ikusi historia",
|
||||
"report_failure": "Jakinarazi akatsa",
|
||||
"mark_repaired": "Markatu konpondu",
|
||||
"close": "Itxi",
|
||||
"cost": "Kostua",
|
||||
"edit": "Editatu"
|
||||
@@ -145,5 +151,34 @@
|
||||
"critical": "Larria",
|
||||
"warning": "Abisua",
|
||||
"info": "Informazioa"
|
||||
},
|
||||
"report_failure": {
|
||||
"title": "Jakinarazi makinaren akatsa",
|
||||
"equipment_info": {
|
||||
"title": "Makinaren informazioa"
|
||||
}
|
||||
},
|
||||
"failure_types": {
|
||||
"mechanical": "Mekanikoa",
|
||||
"electrical": "Elektrikoa",
|
||||
"software": "Softwarea",
|
||||
"temperature": "Tenperatura",
|
||||
"other": "Bestelakoa"
|
||||
},
|
||||
"severity": {
|
||||
"low": "Baxua",
|
||||
"medium": "Ertaina",
|
||||
"high": "Altua",
|
||||
"urgent": "Presazkoa"
|
||||
},
|
||||
"hints": {
|
||||
"failure_description": "Deskribatu behaturiko arazoa xehetasunez",
|
||||
"failure_photos": "Erantsi makinaren edo arazoaren argazkiak diagnosia errazteko"
|
||||
},
|
||||
"mark_repaired": {
|
||||
"title": "Markatu makina konponduta",
|
||||
"repair_info": {
|
||||
"title": "Konponketaren informazioa"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,14 @@ import { EquipmentModal } from '../../../../components/domain/equipment/Equipmen
|
||||
import { DeleteEquipmentModal } from '../../../../components/domain/equipment/DeleteEquipmentModal';
|
||||
import { MaintenanceHistoryModal } from '../../../../components/domain/equipment/MaintenanceHistoryModal';
|
||||
import { ScheduleMaintenanceModal, type MaintenanceScheduleData } from '../../../../components/domain/equipment/ScheduleMaintenanceModal';
|
||||
import { ReportFailureModal } from '../../../../components/domain/equipment/ReportFailureModal';
|
||||
import { MarkAsRepairedModal } from '../../../../components/domain/equipment/MarkAsRepairedModal';
|
||||
import { useEquipment, useCreateEquipment, useUpdateEquipment, useDeleteEquipment, useHardDeleteEquipment } from '../../../../api/hooks/equipment';
|
||||
import { equipmentService } from '../../../../api/services/equipment';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const MaquinariaPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation(['equipment', 'common']);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
@@ -26,6 +31,8 @@ const MaquinariaPage: React.FC = () => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
||||
const [showReportFailureModal, setShowReportFailureModal] = useState(false);
|
||||
const [showMarkRepairedModal, setShowMarkRepairedModal] = useState(false);
|
||||
const [equipmentForAction, setEquipmentForAction] = useState<Equipment | null>(null);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
@@ -82,11 +89,75 @@ const MaquinariaPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReportFailure = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowReportFailureModal(true);
|
||||
};
|
||||
|
||||
const handleMarkAsRepaired = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowMarkRepairedModal(true);
|
||||
};
|
||||
|
||||
const handleScheduleMaintenance = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowScheduleModal(true);
|
||||
};
|
||||
|
||||
const handleReportFailureSubmit = async (equipmentId: string, failureData: {
|
||||
failureType: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
photos?: File[];
|
||||
estimatedImpact: boolean;
|
||||
}) => {
|
||||
try {
|
||||
// Use the new API endpoint
|
||||
await equipmentService.reportEquipmentFailure(
|
||||
tenantId,
|
||||
equipmentId,
|
||||
failureData
|
||||
);
|
||||
|
||||
// Refresh equipment data
|
||||
await queryClient.invalidateQueries({ queryKey: ['equipment', tenantId] });
|
||||
|
||||
setShowReportFailureModal(false);
|
||||
setEquipmentForAction(null);
|
||||
} catch (error) {
|
||||
console.error('Error reporting failure:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAsRepairedSubmit = async (equipmentId: string, repairData: {
|
||||
repairDate: string;
|
||||
technicianName: string;
|
||||
repairDescription: string;
|
||||
partsReplaced: string[];
|
||||
cost: number;
|
||||
photos?: File[];
|
||||
testResults: boolean;
|
||||
}) => {
|
||||
try {
|
||||
// Use the new API endpoint
|
||||
await equipmentService.markEquipmentAsRepaired(
|
||||
tenantId,
|
||||
equipmentId,
|
||||
repairData
|
||||
);
|
||||
|
||||
// Refresh equipment data
|
||||
await queryClient.invalidateQueries({ queryKey: ['equipment', tenantId] });
|
||||
|
||||
setShowMarkRepairedModal(false);
|
||||
setEquipmentForAction(null);
|
||||
} catch (error) {
|
||||
console.error('Error marking as repaired:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleMaintenanceSubmit = async (equipmentId: string, maintenanceData: MaintenanceScheduleData) => {
|
||||
try {
|
||||
// Update next maintenance date based on scheduled date
|
||||
@@ -104,6 +175,8 @@ const MaquinariaPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleViewMaintenanceHistory = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowHistoryModal(true);
|
||||
@@ -380,6 +453,28 @@ const MaquinariaPage: React.FC = () => {
|
||||
priority: 'primary',
|
||||
onClick: () => handleShowMaintenanceDetails(equipment)
|
||||
},
|
||||
// Context-aware actions based on equipment status
|
||||
...(equipment.status === 'operational' || equipment.status === 'warning' ? [
|
||||
{
|
||||
label: t('actions.report_failure'),
|
||||
icon: AlertTriangle,
|
||||
variant: 'outline' as const,
|
||||
priority: 'secondary' as const,
|
||||
highlighted: true,
|
||||
destructive: true,
|
||||
onClick: () => handleReportFailure(equipment)
|
||||
}
|
||||
] : []),
|
||||
...(equipment.status === 'down' ? [
|
||||
{
|
||||
label: t('actions.mark_repaired'),
|
||||
icon: CheckCircle,
|
||||
variant: 'primary' as const,
|
||||
priority: 'secondary' as const,
|
||||
highlighted: true,
|
||||
onClick: () => handleMarkAsRepaired(equipment)
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
label: t('actions.view_history'),
|
||||
icon: History,
|
||||
@@ -459,6 +554,34 @@ const MaquinariaPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Report Failure Modal */}
|
||||
{showReportFailureModal && equipmentForAction && (
|
||||
<ReportFailureModal
|
||||
isOpen={showReportFailureModal}
|
||||
onClose={() => {
|
||||
setShowReportFailureModal(false);
|
||||
setEquipmentForAction(null);
|
||||
}}
|
||||
equipment={equipmentForAction}
|
||||
onReportFailure={(failureData) => handleReportFailureSubmit(equipmentForAction.id, failureData)}
|
||||
isLoading={updateEquipmentMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mark as Repaired Modal */}
|
||||
{showMarkRepairedModal && equipmentForAction && (
|
||||
<MarkAsRepairedModal
|
||||
isOpen={showMarkRepairedModal}
|
||||
onClose={() => {
|
||||
setShowMarkRepairedModal(false);
|
||||
setEquipmentForAction(null);
|
||||
}}
|
||||
equipment={equipmentForAction}
|
||||
onMarkAsRepaired={(repairData) => handleMarkAsRepairedSubmit(equipmentForAction.id, repairData)}
|
||||
isLoading={updateEquipmentMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Schedule Maintenance Modal */}
|
||||
{showScheduleModal && equipmentForAction && (
|
||||
<ScheduleMaintenanceModal
|
||||
|
||||
Reference in New Issue
Block a user