Add equipment fail feature
This commit is contained in:
185
Lista_de_Mejoras_Propuestas.md
Normal file
185
Lista_de_Mejoras_Propuestas.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Lista de Mejoras Propuestas
|
||||||
|
|
||||||
|
## 1. Nueva Sección: Seguridad y Ciberseguridad (Añadir después de 5.2)
|
||||||
|
|
||||||
|
### 5.3. Arquitectura de Seguridad y Cumplimiento Normativo Europeo
|
||||||
|
|
||||||
|
**Autenticación y Autorización:**
|
||||||
|
- JWT con rotación de tokens cada 15 minutos
|
||||||
|
- Control de acceso basado en roles (RBAC)
|
||||||
|
- Rate limiting (300 req/min por cliente)
|
||||||
|
- Autenticación multifactor (MFA) planificada para Q1 2026
|
||||||
|
|
||||||
|
**Protección de Datos:**
|
||||||
|
- Cifrado AES-256 en reposo
|
||||||
|
- HTTPS obligatorio con certificados Let's Encrypt auto-renovables
|
||||||
|
- Aislamiento multi-tenant a nivel de base de datos
|
||||||
|
- Prevención SQL injection mediante Pydantic schemas
|
||||||
|
- Protección XSS y CORS
|
||||||
|
|
||||||
|
**Monitorización y Trazabilidad:**
|
||||||
|
- OpenTelemetry para trazabilidad distribuida end-to-end
|
||||||
|
- SigNoz como plataforma unificada de observabilidad
|
||||||
|
- Logs centralizados con correlación de trazas
|
||||||
|
- Auditoría completa de accesos y cambios
|
||||||
|
- Alertas en tiempo real (email y Slack)
|
||||||
|
|
||||||
|
**Cumplimiento RGPD:**
|
||||||
|
- Privacy by design
|
||||||
|
- Derecho al olvido implementado
|
||||||
|
- Exportación de datos en CSV/JSON
|
||||||
|
- Gestión de consentimientos con historial
|
||||||
|
- Anonimización de datos analíticos
|
||||||
|
|
||||||
|
**Infraestructura Segura:**
|
||||||
|
- Kubernetes con políticas de seguridad de pods
|
||||||
|
- Actualizaciones automáticas de seguridad
|
||||||
|
- Backups cifrados diarios
|
||||||
|
- Plan de recuperación ante desastres (DR)
|
||||||
|
- PostgreSQL 17 con configuraciones hardened
|
||||||
|
|
||||||
|
## 2. Actualizar Sección 4.4 (Competencia) - Añadir Ventaja de Seguridad
|
||||||
|
|
||||||
|
**Ventaja Competitiva en Seguridad:**
|
||||||
|
- Arquitectura "Security-First" vs. soluciones legacy vulnerables
|
||||||
|
- Cumplimiento RGPD desde el diseño (competidores retrofitting)
|
||||||
|
- Observabilidad completa (OpenTelemetry + SigNoz) vs. cajas negras
|
||||||
|
- Certificaciones de seguridad planificadas (ISO 27001, ENS)
|
||||||
|
- Alineación con NIS2 Directive (obligatoria 2024 para cadena alimentaria)
|
||||||
|
|
||||||
|
## 3. Actualizar Sección 8.1 (Financiación) - Nuevas Líneas de Ayuda
|
||||||
|
|
||||||
|
### Añadir subsección: "Financiación Europea en Ciberseguridad 2026-2027"
|
||||||
|
|
||||||
|
**Programas Europeos Identificados (2026-2027):**
|
||||||
|
|
||||||
|
1. **Digital Europe Programme - Ciberseguridad (€390M totales)**
|
||||||
|
- **UPTAKE Program**: €15M para SMEs en cumplimiento normativo
|
||||||
|
- **AI-Based Cybersecurity**: €15M para sistemas IA de seguridad
|
||||||
|
- Cofinanciación: hasta 75% de costes de proyecto
|
||||||
|
- Proyectos: €3-5M por proyecto, duración 36 meses
|
||||||
|
- **Elegibilidad**: Bakery-IA califica como SME tecnológica
|
||||||
|
- **Solicitud estimada**: €200.000 (Q1 2026)
|
||||||
|
|
||||||
|
2. **INCIBE EMPRENDE Program (España Digital 2026)**
|
||||||
|
- Presupuesto: €191M (2023-2026)
|
||||||
|
- 34 entidades colaboradoras en España
|
||||||
|
- Ideación, incubación y aceleración en ciberseguridad
|
||||||
|
- Fondos Next Generation-EU
|
||||||
|
- **Solicitud estimada**: €50.000 (Q2 2026)
|
||||||
|
|
||||||
|
3. **ENISA Emprendedoras Digitales**
|
||||||
|
- Préstamos participativos hasta €51M movilizables
|
||||||
|
- Líneas específicas para emprendimiento digital
|
||||||
|
- Sin avales personales, condiciones flexibles
|
||||||
|
- **Solicitud estimada**: €75.000 (Q2 2026)
|
||||||
|
|
||||||
|
**Alineación con Prioridades UE:**
|
||||||
|
- NIS2 Directive (seguridad sector alimentario)
|
||||||
|
- Cyber Resilience Act (productos digitales seguros)
|
||||||
|
- AI Act (IA transparente y auditable)
|
||||||
|
- GDPR (protección datos desde diseño)
|
||||||
|
|
||||||
|
## 4. Actualizar Sección 5.2 (Arquitectura Técnica)
|
||||||
|
|
||||||
|
### Añadir detalles de la documentación técnica:
|
||||||
|
|
||||||
|
**Arquitectura de Microservicios (21 servicios independientes):**
|
||||||
|
- API Gateway centralizado con JWT y cache Redis (95% hit rate)
|
||||||
|
- Frontend: React 18 + TypeScript (PWA mobile-first)
|
||||||
|
- 18 bases de datos PostgreSQL 17 (patrón database-per-service)
|
||||||
|
- Redis 7.4 para caché y RabbitMQ 4.1 para eventos
|
||||||
|
- Kubernetes en VPS (escalabilidad horizontal)
|
||||||
|
|
||||||
|
**Innovación Técnica Destacable:**
|
||||||
|
- **Sistema de Alertas Enriquecidas** (3 niveles: Alertas/Notificaciones/Recomendaciones)
|
||||||
|
- **Priorización Inteligente** con scoring 0-100 (4 factores ponderados)
|
||||||
|
- **Escalado Temporal** (+10 a 48h, +20 a 72h, +30 cerca deadline)
|
||||||
|
- **Encadenamiento Causal** (stock shortage → retraso producción → riesgo pedido)
|
||||||
|
- **Deduplicación** (95% reducción spam de alertas)
|
||||||
|
- **SSE + WebSocket** para actualizaciones en tiempo real
|
||||||
|
- **Prophet ML** con 20+ features (AEMET, tráfico Madrid, festivos)
|
||||||
|
|
||||||
|
**Observabilidad de Clase Empresarial:**
|
||||||
|
- **SigNoz**: Trazas, métricas y logs unificados
|
||||||
|
- **OpenTelemetry**: Auto-instrumentación de 18 servicios
|
||||||
|
- **ClickHouse**: Backend de alto rendimiento para análisis
|
||||||
|
- **Alerting**: Multi-canal (email, Slack) vía AlertManager
|
||||||
|
- Monitorización: 18 DBs PostgreSQL, Redis, RabbitMQ, Kubernetes
|
||||||
|
|
||||||
|
## 5. Actualizar Resumen Ejecutivo (Sección 0)
|
||||||
|
|
||||||
|
### Añadir bullet:
|
||||||
|
|
||||||
|
- **Seguridad y Cumplimiento:** Arquitectura Security-First con cumplimiento RGPD, observabilidad completa (OpenTelemetry/SigNoz), y alineación con normativas europeas (NIS2, Cyber Resilience Act). Elegible para €390M del Digital Europe Programme en ciberseguridad.
|
||||||
|
|
||||||
|
## 6. Actualizar Sección 9 (Decálogo) - Añadir Oportunidades de Financiación
|
||||||
|
|
||||||
|
**6. Alineación Estratégica con Prioridades Europeas 2026-2027:**
|
||||||
|
- **€390M disponibles** en Digital Europe Programme para ciberseguridad
|
||||||
|
- **€191M** del programa INCIBE EMPRENDE (España Digital 2026)
|
||||||
|
- Bakery-IA califica para **3 líneas de financiación simultáneas**:
|
||||||
|
* UPTAKE (€200K) - Cumplimiento normativo SMEs
|
||||||
|
* INCIBE EMPRENDE (€50K) - Aceleración cybersecurity startups
|
||||||
|
* ENISA Digital (€75K) - Préstamo participativo
|
||||||
|
- **Total potencial**: €325.000 en financiación no dilutiva adicional
|
||||||
|
- Ventaja competitiva: Security-First vs. competidores legacy
|
||||||
|
|
||||||
|
## 7. Nueva Tabla de Costes Operativos - Añadir Línea de Seguridad
|
||||||
|
|
||||||
|
| **Seguridad y Cumplimiento** | | |
|
||||||
|
| Certificado SSL (Let's Encrypt) | Gratuito | €0 | Renovación automática |
|
||||||
|
| SigNoz Observability (self-hosted) | Incluido en VPS | €0 | Vs. €500+/mes en SaaS |
|
||||||
|
| Auditoría RGPD anual | Externa | €1,200/año | Compliance obligatorio |
|
||||||
|
| Backups cifrados (off-site) | Backblaze B2 | €5/mes | €60/año |
|
||||||
|
|
||||||
|
## 8. Actualizar Roadmap (Sección 9) - Añadir Hitos de Seguridad
|
||||||
|
|
||||||
|
**Q1 2026:**
|
||||||
|
- ✅ Implementación MFA (Multi-Factor Authentication)
|
||||||
|
- ✅ Solicitud Digital Europe Programme (UPTAKE)
|
||||||
|
- ✅ Auditoría RGPD externa
|
||||||
|
|
||||||
|
**Q2 2026:**
|
||||||
|
- 📋 Certificación ISO 27001 (inicio proceso)
|
||||||
|
- 📋 Implementación NIS2 compliance
|
||||||
|
- 📋 Solicitud INCIBE EMPRENDE
|
||||||
|
|
||||||
|
**Q3 2026:**
|
||||||
|
- 📋 Penetration testing externo
|
||||||
|
- 📋 Certificación ENS (Esquema Nacional de Seguridad)
|
||||||
|
|
||||||
|
## 9. Actualizar Petición Concreta a VUE (Sección 9.2)
|
||||||
|
|
||||||
|
### Añadir nuevo punto 5:
|
||||||
|
|
||||||
|
**5. Conexión con Programas Europeos de Ciberseguridad:**
|
||||||
|
- Orientación para solicitud Digital Europe Programme (UPTAKE: €200K)
|
||||||
|
- Introducción a INCIBE EMPRENDE y red de 34 entidades colaboradoras
|
||||||
|
- Asesoramiento en preparación de propuestas técnicas para fondos EU
|
||||||
|
- Contacto con CDTI para programa NEOTEC (R&D+Ciberseguridad)
|
||||||
|
|
||||||
|
## 10. Añadir Anexo 7 - Compliance y Certificaciones
|
||||||
|
|
||||||
|
## ANEXO 7: ROADMAP DE COMPLIANCE Y CERTIFICACIONES
|
||||||
|
|
||||||
|
**Normativas Aplicables:**
|
||||||
|
- ✅ RGPD (Reglamento General de Protección de Datos) - Implementado
|
||||||
|
- 📋 NIS2 Directive (Seguridad de redes y sistemas de información) - Q2 2026
|
||||||
|
- 📋 Cyber Resilience Act (Productos digitales seguros) - Q3 2026
|
||||||
|
- 📋 AI Act (Transparencia y auditoría de IA) - Q4 2026
|
||||||
|
|
||||||
|
**Certificaciones Planificadas:**
|
||||||
|
- 📋 ISO 27001 (Gestión de Seguridad de la Información) - 12-18 meses
|
||||||
|
- 📋 ENS Medio (Esquema Nacional de Seguridad) - 6-9 meses
|
||||||
|
- 📋 SOC 2 Type II (para clientes Enterprise) - 18-24 meses
|
||||||
|
|
||||||
|
**Inversión Estimada en Compliance (3 años):** €25,000
|
||||||
|
**ROI Esperado:** Acceso a clientes Enterprise (+€150K ARR potencial)
|
||||||
|
|
||||||
|
## Resumen de Cambios Cuantitativos:
|
||||||
|
- Nueva financiación identificada: €325.000 (vs. €18.000 original)
|
||||||
|
- Nuevos programas: 3 líneas europeas de ciberseguridad
|
||||||
|
- Secciones nuevas: 2 (Seguridad 5.3, Compliance Anexo 7)
|
||||||
|
- Actualizaciones: 8 secciones existentes mejoradas
|
||||||
|
- Ventaja competitiva: Security-First enfatizada en 4 lugares
|
||||||
@@ -28,7 +28,7 @@ class EquipmentService {
|
|||||||
model: response.model || '',
|
model: response.model || '',
|
||||||
serialNumber: response.serial_number || '',
|
serialNumber: response.serial_number || '',
|
||||||
location: response.location || '',
|
location: response.location || '',
|
||||||
status: response.status,
|
status: response.status.toLowerCase() as Equipment['status'],
|
||||||
installDate: response.install_date || new Date().toISOString().split('T')[0],
|
installDate: response.install_date || new Date().toISOString().split('T')[0],
|
||||||
lastMaintenance: response.last_maintenance_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],
|
nextMaintenance: response.next_maintenance_date || new Date().toISOString().split('T')[0],
|
||||||
@@ -52,6 +52,7 @@ class EquipmentService {
|
|||||||
weight: response.weight_kg || 0
|
weight: response.weight_kg || 0
|
||||||
},
|
},
|
||||||
is_active: response.is_active,
|
is_active: response.is_active,
|
||||||
|
support_contact: response.support_contact || undefined,
|
||||||
created_at: response.created_at,
|
created_at: response.created_at,
|
||||||
updated_at: response.updated_at
|
updated_at: response.updated_at
|
||||||
};
|
};
|
||||||
@@ -80,7 +81,8 @@ class EquipmentService {
|
|||||||
weight_kg: equipment.specifications?.weight,
|
weight_kg: equipment.specifications?.weight,
|
||||||
current_temperature: equipment.temperature,
|
current_temperature: equipment.temperature,
|
||||||
target_temperature: equipment.targetTemperature,
|
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;
|
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();
|
export const equipmentService = new EquipmentService();
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export interface Equipment {
|
|||||||
maintenanceHistory: MaintenanceHistory[];
|
maintenanceHistory: MaintenanceHistory[];
|
||||||
specifications: EquipmentSpecifications;
|
specifications: EquipmentSpecifications;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
support_contact?: {
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
company?: string;
|
||||||
|
contract_number?: string;
|
||||||
|
response_time_sla?: number;
|
||||||
|
};
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@@ -81,6 +88,13 @@ export interface EquipmentCreate {
|
|||||||
current_temperature?: number;
|
current_temperature?: number;
|
||||||
target_temperature?: number;
|
target_temperature?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
support_contact?: {
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
company?: string;
|
||||||
|
contract_number?: string;
|
||||||
|
response_time_sla?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EquipmentUpdate {
|
export interface EquipmentUpdate {
|
||||||
@@ -129,6 +143,13 @@ export interface EquipmentResponse {
|
|||||||
target_temperature: number | null;
|
target_temperature: number | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
support_contact: {
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
company?: string;
|
||||||
|
contract_number?: string;
|
||||||
|
response_time_sla?: number;
|
||||||
|
} | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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 {
|
export function getPriorityColor(level: PriorityLevel | string): string {
|
||||||
switch (level) {
|
const levelValue = String(level);
|
||||||
case PriorityLevel.CRITICAL:
|
|
||||||
case 'critical':
|
if (levelValue === PriorityLevel.CRITICAL || levelValue === 'critical') {
|
||||||
return 'var(--color-error)';
|
return 'var(--color-error)';
|
||||||
case PriorityLevel.IMPORTANT:
|
} else if (levelValue === PriorityLevel.IMPORTANT || levelValue === 'important') {
|
||||||
case 'important':
|
return 'var(--color-warning)';
|
||||||
return 'var(--color-warning)';
|
} else if (levelValue === PriorityLevel.STANDARD || levelValue === 'standard') {
|
||||||
case PriorityLevel.STANDARD:
|
return 'var(--color-info)';
|
||||||
case 'standard':
|
} else if (levelValue === PriorityLevel.INFO || levelValue === 'info') {
|
||||||
return 'var(--color-info)';
|
return 'var(--color-success)';
|
||||||
case PriorityLevel.INFO:
|
} else {
|
||||||
case 'info':
|
return 'var(--color-info)';
|
||||||
return 'var(--color-success)';
|
|
||||||
default:
|
|
||||||
return 'var(--color-info)';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPriorityIcon(level: PriorityLevel | string): string {
|
export function getPriorityIcon(level: PriorityLevel | string): string {
|
||||||
switch (level) {
|
const levelValue = String(level);
|
||||||
case PriorityLevel.CRITICAL:
|
|
||||||
case 'critical':
|
if (levelValue === PriorityLevel.CRITICAL || levelValue === 'critical') {
|
||||||
return 'alert-triangle';
|
return 'alert-triangle';
|
||||||
case PriorityLevel.IMPORTANT:
|
} else if (levelValue === PriorityLevel.IMPORTANT || levelValue === 'important') {
|
||||||
case 'important':
|
return 'alert-circle';
|
||||||
return 'alert-circle';
|
} else if (levelValue === PriorityLevel.STANDARD || levelValue === 'standard') {
|
||||||
case PriorityLevel.STANDARD:
|
return 'info';
|
||||||
case 'standard':
|
} else if (levelValue === PriorityLevel.INFO || levelValue === 'info') {
|
||||||
return 'info';
|
return 'check-circle';
|
||||||
case PriorityLevel.INFO:
|
} else {
|
||||||
case 'info':
|
return 'info';
|
||||||
return 'check-circle';
|
|
||||||
default:
|
|
||||||
return 'info';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTypeClassBadgeVariant(
|
export function getTypeClassBadgeVariant(
|
||||||
typeClass: AlertTypeClass | string
|
typeClass: AlertTypeClass | string
|
||||||
): 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'outline' {
|
): 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'outline' {
|
||||||
switch (typeClass) {
|
// Convert to string and compare with known values
|
||||||
case AlertTypeClass.ACTION_NEEDED:
|
const typeValue = String(typeClass);
|
||||||
case 'action_needed':
|
|
||||||
return 'error';
|
if (typeValue === AlertTypeClass.ACTION_NEEDED || typeValue === 'action_needed') {
|
||||||
case AlertTypeClass.PREVENTED_ISSUE:
|
return 'error';
|
||||||
case 'prevented_issue':
|
} else if (typeValue === AlertTypeClass.PREVENTED_ISSUE || typeValue === 'prevented_issue') {
|
||||||
return 'success';
|
return 'success';
|
||||||
case AlertTypeClass.TREND_WARNING:
|
} else if (typeValue === AlertTypeClass.TREND_WARNING || typeValue === 'trend_warning') {
|
||||||
case 'trend_warning':
|
return 'warning';
|
||||||
return 'warning';
|
} else if (typeValue === AlertTypeClass.ESCALATION || typeValue === 'escalation') {
|
||||||
case AlertTypeClass.ESCALATION:
|
return 'error';
|
||||||
case 'escalation':
|
} else if (typeValue === AlertTypeClass.INFORMATION || typeValue === 'information') {
|
||||||
return 'error';
|
return 'info';
|
||||||
case AlertTypeClass.INFORMATION:
|
} else {
|
||||||
case 'information':
|
return 'default';
|
||||||
return 'info';
|
|
||||||
default:
|
|
||||||
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 Button } from './Button';
|
||||||
export { default as Input } from './Input';
|
export { default as Input } from './Input';
|
||||||
export { default as Textarea } from './Textarea/Textarea';
|
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 Card, CardHeader, CardBody, CardFooter, CardContent, CardTitle, CardDescription } from './Card';
|
||||||
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
|
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
|
||||||
export { default as Table } from './Table';
|
export { default as Table } from './Table';
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
"duration": "Duration (hours)",
|
"duration": "Duration (hours)",
|
||||||
"parts_needed": "Parts Needed",
|
"parts_needed": "Parts Needed",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
|
"failure_type": "Failure Type",
|
||||||
|
"severity": "Severity",
|
||||||
|
"photos": "Photos",
|
||||||
|
"estimated_impact": "Production Impact",
|
||||||
"specifications": {
|
"specifications": {
|
||||||
"power": "Power",
|
"power": "Power",
|
||||||
"capacity": "Capacity",
|
"capacity": "Capacity",
|
||||||
@@ -69,6 +73,8 @@
|
|||||||
"acknowledge_alert": "Acknowledge Alert",
|
"acknowledge_alert": "Acknowledge Alert",
|
||||||
"view_details": "View Details",
|
"view_details": "View Details",
|
||||||
"view_history": "View History",
|
"view_history": "View History",
|
||||||
|
"report_failure": "Report Failure",
|
||||||
|
"mark_repaired": "Mark as Repaired",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"cost": "Cost",
|
"cost": "Cost",
|
||||||
"edit": "Edit"
|
"edit": "Edit"
|
||||||
@@ -148,5 +154,34 @@
|
|||||||
"critical": "Critical",
|
"critical": "Critical",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
"info": "Information"
|
"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)",
|
"duration": "Duración (horas)",
|
||||||
"parts_needed": "Repuestos Necesarios",
|
"parts_needed": "Repuestos Necesarios",
|
||||||
"description": "Descripción",
|
"description": "Descripción",
|
||||||
|
"failure_type": "Tipo de Fallo",
|
||||||
|
"severity": "Gravedad",
|
||||||
|
"photos": "Fotos",
|
||||||
|
"estimated_impact": "Impacto en la Producción",
|
||||||
"specifications": {
|
"specifications": {
|
||||||
"power": "Potencia",
|
"power": "Potencia",
|
||||||
"capacity": "Capacidad",
|
"capacity": "Capacidad",
|
||||||
@@ -69,6 +73,8 @@
|
|||||||
"acknowledge_alert": "Reconocer Alerta",
|
"acknowledge_alert": "Reconocer Alerta",
|
||||||
"view_details": "Ver Detalles",
|
"view_details": "Ver Detalles",
|
||||||
"view_history": "Ver Historial",
|
"view_history": "Ver Historial",
|
||||||
|
"report_failure": "Reportar Fallo",
|
||||||
|
"mark_repaired": "Marcar Reparado",
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
"cost": "Costo",
|
"cost": "Costo",
|
||||||
"edit": "Editar"
|
"edit": "Editar"
|
||||||
@@ -148,5 +154,34 @@
|
|||||||
"critical": "Crítico",
|
"critical": "Crítico",
|
||||||
"warning": "Advertencia",
|
"warning": "Advertencia",
|
||||||
"info": "Información"
|
"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)",
|
"duration": "Iraupena (orduak)",
|
||||||
"parts_needed": "Behar diren piezak",
|
"parts_needed": "Behar diren piezak",
|
||||||
"description": "Deskribapena",
|
"description": "Deskribapena",
|
||||||
|
"failure_type": "Akats mota",
|
||||||
|
"severity": "Larritasuna",
|
||||||
|
"photos": "Argazkiak",
|
||||||
|
"estimated_impact": "Ekoizpenaren gaineko eragina",
|
||||||
"specifications": {
|
"specifications": {
|
||||||
"power": "Potentzia",
|
"power": "Potentzia",
|
||||||
"capacity": "Edukiera",
|
"capacity": "Edukiera",
|
||||||
@@ -69,6 +73,8 @@
|
|||||||
"acknowledge_alert": "Berretsi alerta",
|
"acknowledge_alert": "Berretsi alerta",
|
||||||
"view_details": "Ikusi xehetasunak",
|
"view_details": "Ikusi xehetasunak",
|
||||||
"view_history": "Ikusi historia",
|
"view_history": "Ikusi historia",
|
||||||
|
"report_failure": "Jakinarazi akatsa",
|
||||||
|
"mark_repaired": "Markatu konpondu",
|
||||||
"close": "Itxi",
|
"close": "Itxi",
|
||||||
"cost": "Kostua",
|
"cost": "Kostua",
|
||||||
"edit": "Editatu"
|
"edit": "Editatu"
|
||||||
@@ -145,5 +151,34 @@
|
|||||||
"critical": "Larria",
|
"critical": "Larria",
|
||||||
"warning": "Abisua",
|
"warning": "Abisua",
|
||||||
"info": "Informazioa"
|
"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 { DeleteEquipmentModal } from '../../../../components/domain/equipment/DeleteEquipmentModal';
|
||||||
import { MaintenanceHistoryModal } from '../../../../components/domain/equipment/MaintenanceHistoryModal';
|
import { MaintenanceHistoryModal } from '../../../../components/domain/equipment/MaintenanceHistoryModal';
|
||||||
import { ScheduleMaintenanceModal, type MaintenanceScheduleData } from '../../../../components/domain/equipment/ScheduleMaintenanceModal';
|
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 { 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 MaquinariaPage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation(['equipment', 'common']);
|
const { t } = useTranslation(['equipment', 'common']);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
@@ -26,6 +31,8 @@ const MaquinariaPage: React.FC = () => {
|
|||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||||
const [showScheduleModal, setShowScheduleModal] = 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 [equipmentForAction, setEquipmentForAction] = useState<Equipment | null>(null);
|
||||||
|
|
||||||
const currentTenant = useCurrentTenant();
|
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) => {
|
const handleScheduleMaintenance = (equipment: Equipment) => {
|
||||||
setEquipmentForAction(equipment);
|
setEquipmentForAction(equipment);
|
||||||
setShowScheduleModal(true);
|
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) => {
|
const handleScheduleMaintenanceSubmit = async (equipmentId: string, maintenanceData: MaintenanceScheduleData) => {
|
||||||
try {
|
try {
|
||||||
// Update next maintenance date based on scheduled date
|
// Update next maintenance date based on scheduled date
|
||||||
@@ -104,6 +175,8 @@ const MaquinariaPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleViewMaintenanceHistory = (equipment: Equipment) => {
|
const handleViewMaintenanceHistory = (equipment: Equipment) => {
|
||||||
setEquipmentForAction(equipment);
|
setEquipmentForAction(equipment);
|
||||||
setShowHistoryModal(true);
|
setShowHistoryModal(true);
|
||||||
@@ -380,6 +453,28 @@ const MaquinariaPage: React.FC = () => {
|
|||||||
priority: 'primary',
|
priority: 'primary',
|
||||||
onClick: () => handleShowMaintenanceDetails(equipment)
|
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'),
|
label: t('actions.view_history'),
|
||||||
icon: 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 */}
|
{/* Schedule Maintenance Modal */}
|
||||||
{showScheduleModal && equipmentForAction && (
|
{showScheduleModal && equipmentForAction && (
|
||||||
<ScheduleMaintenanceModal
|
<ScheduleMaintenanceModal
|
||||||
|
|||||||
165
services/notification/app/templates/equipment_failure_email.html
Normal file
165
services/notification/app/templates/equipment_failure_email.html
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🚨 URGENTE: Fallo de Equipo - {{ equipment_name }}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', 'Helvetica', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #dc2626;
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.equipment-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.equipment-info dt {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.equipment-info dd {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.failure-details {
|
||||||
|
background-color: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #dc2626;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.support-contact {
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🚨 URGENTE: Fallo de Equipo</h1>
|
||||||
|
<p>Acción Inmediata Requerida</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Estimado Equipo de Soporte,</p>
|
||||||
|
|
||||||
|
<p>Se ha reportado un fallo en el equipo <strong>{{ equipment_name }}</strong> en {{ equipment_location }}. Esto requiere su atención inmediata.</p>
|
||||||
|
|
||||||
|
<div class="equipment-info">
|
||||||
|
<h2>Información del Equipo</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Nombre del Equipo:</dt>
|
||||||
|
<dd>{{ equipment_name }}</dd>
|
||||||
|
|
||||||
|
<dt>Tipo:</dt>
|
||||||
|
<dd>{{ equipment_type }}</dd>
|
||||||
|
|
||||||
|
<dt>Modelo:</dt>
|
||||||
|
<dd>{{ equipment_model }}</dd>
|
||||||
|
|
||||||
|
<dt>Número de Serie:</dt>
|
||||||
|
<dd>{{ equipment_serial_number }}</dd>
|
||||||
|
|
||||||
|
<dt>Ubicación:</dt>
|
||||||
|
<dd>{{ equipment_location }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="failure-details">
|
||||||
|
<h2>Detalles del Fallo</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Tipo de Fallo:</dt>
|
||||||
|
<dd>{{ failure_type }}</dd>
|
||||||
|
|
||||||
|
<dt>Gravedad:</dt>
|
||||||
|
<dd><strong style="color: {{ 'red' if severity == 'urgent' else 'orange' }};">{{ severity.upper() }}</strong></dd>
|
||||||
|
|
||||||
|
<dt>Descripción:</dt>
|
||||||
|
<dd>{{ description }}</dd>
|
||||||
|
|
||||||
|
<dt>Fecha/Hora Reportado:</dt>
|
||||||
|
<dd>{{ reported_time }}</dd>
|
||||||
|
|
||||||
|
<dt>Impacto Estimado:</dt>
|
||||||
|
<dd>{{ 'SÍ - Afecta producción' if estimated_impact else 'NO - Sin impacto en producción' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Este equipo ha sido marcado automáticamente como <strong>FUERA DE SERVICIO</strong> y retirado de producción para evitar más problemas.</p>
|
||||||
|
|
||||||
|
<div class="support-contact">
|
||||||
|
<h2>Información de Contacto de Soporte</h2>
|
||||||
|
{% if support_contact.email %}
|
||||||
|
<p><strong>Email:</strong> {{ support_contact.email }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if support_contact.phone %}
|
||||||
|
<p><strong>Teléfono:</strong> {{ support_contact.phone }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if support_contact.company %}
|
||||||
|
<p><strong>Empresa:</strong> {{ support_contact.company }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if support_contact.contract_number %}
|
||||||
|
<p><strong>Número de Contrato:</strong> {{ support_contact.contract_number }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if support_contact.response_time_sla %}
|
||||||
|
<p><strong>Tiempo de Respuesta Esperado:</strong> {{ support_contact.response_time_sla }} horas</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><a href="{{ equipment_link }}" class="action-button">Ver Detalles del Equipo</a></p>
|
||||||
|
|
||||||
|
<p>Por favor responda dentro del plazo de SLA y actualice el sistema cuando la reparación esté completada.</p>
|
||||||
|
|
||||||
|
<p>Gracias,<br>
|
||||||
|
Equipo de {{ bakery_name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Esta es una notificación automática del Sistema de Monitoreo de Equipos de {{ bakery_name }}</p>
|
||||||
|
<p>© {{ current_year }} {{ bakery_name }}. Todos los derechos reservados.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>✅ Equipo Reparado - {{ equipment_name }}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', 'Helvetica', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.equipment-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.equipment-info dt {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.equipment-info dd {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.repair-details {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
border: 1px solid #a3e635;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.action-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.downtime-summary {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>✅ Equipo Reparado</h1>
|
||||||
|
<p>Equipo de Vuelta en Servicio</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Estimado Equipo,</p>
|
||||||
|
|
||||||
|
<p>Nos complace informarle que el equipo <strong>{{ equipment_name }}</strong> ha sido reparado y vuelto al servicio con éxito.</p>
|
||||||
|
|
||||||
|
<div class="equipment-info">
|
||||||
|
<h2>Información del Equipo</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Nombre del Equipo:</dt>
|
||||||
|
<dd>{{ equipment_name }}</dd>
|
||||||
|
|
||||||
|
<dt>Tipo:</dt>
|
||||||
|
<dd>{{ equipment_type }}</dd>
|
||||||
|
|
||||||
|
<dt>Modelo:</dt>
|
||||||
|
<dd>{{ equipment_model }}</dd>
|
||||||
|
|
||||||
|
<dt>Ubicación:</dt>
|
||||||
|
<dd>{{ equipment_location }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repair-details">
|
||||||
|
<h2>Detalles de la Reparación</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Fecha de Reparación:</dt>
|
||||||
|
<dd>{{ repair_date }}</dd>
|
||||||
|
|
||||||
|
<dt>Técnico:</dt>
|
||||||
|
<dd>{{ technician_name }}</dd>
|
||||||
|
|
||||||
|
<dt>Descripción de la Reparación:</dt>
|
||||||
|
<dd>{{ repair_description }}</dd>
|
||||||
|
|
||||||
|
{% if parts_replaced and parts_replaced|length > 0 %}
|
||||||
|
<dt>Piezas Reemplazadas:</dt>
|
||||||
|
<dd>{{ parts_replaced|join(', ') }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<dt>Costo de Reparación:</dt>
|
||||||
|
<dd>€{{ "%.2f"|format(cost) }}</dd>
|
||||||
|
|
||||||
|
<dt>Resultados de Pruebas:</dt>
|
||||||
|
<dd>{{ '✅ Equipo probado y operativo' if test_results else '⚠️ Equipo requiere pruebas adicionales' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="downtime-summary">
|
||||||
|
<h2>Resumen de Tiempo de Inactividad</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Tiempo Total de Inactividad:</dt>
|
||||||
|
<dd>{{ downtime_hours }} horas</dd>
|
||||||
|
|
||||||
|
<dt>Impacto de Costo:</dt>
|
||||||
|
<dd>€{{ "%.2f"|format(cost) }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>El equipo ahora está <strong>OPERATIVO</strong> y disponible para producción.</p>
|
||||||
|
|
||||||
|
<p><a href="{{ equipment_link }}" class="action-button">Ver Detalles del Equipo</a></p>
|
||||||
|
|
||||||
|
<p>Gracias por su pronta atención a este asunto.</p>
|
||||||
|
|
||||||
|
<p>Atentamente,<br>
|
||||||
|
Equipo de {{ bakery_name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Esta es una notificación automática del Sistema de Monitoreo de Equipos de {{ bakery_name }}</p>
|
||||||
|
<p>© {{ current_year }} {{ bakery_name }}. Todos los derechos reservados.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,6 +6,7 @@ Equipment API - CRUD operations on Equipment model
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from datetime import datetime, timezone
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from shared.auth.decorators import get_current_user_dep
|
from shared.auth.decorators import get_current_user_dep
|
||||||
@@ -221,6 +222,175 @@ async def get_equipment_deletion_summary(
|
|||||||
raise HTTPException(status_code=500, detail="Failed to get deletion summary")
|
raise HTTPException(status_code=500, detail="Failed to get deletion summary")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
route_builder.build_base_route("equipment/{equipment_id}/report-failure"),
|
||||||
|
response_model=EquipmentResponse
|
||||||
|
)
|
||||||
|
async def report_equipment_failure(
|
||||||
|
failure_data: dict,
|
||||||
|
request: Request,
|
||||||
|
tenant_id: UUID = Path(...),
|
||||||
|
equipment_id: UUID = Path(...),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
production_service: ProductionService = Depends(get_production_service),
|
||||||
|
db = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Report equipment failure and trigger maintenance workflow"""
|
||||||
|
try:
|
||||||
|
# Update equipment status and add failure record
|
||||||
|
equipment = await production_service.report_equipment_failure(
|
||||||
|
tenant_id,
|
||||||
|
equipment_id,
|
||||||
|
failure_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if not equipment:
|
||||||
|
raise HTTPException(status_code=404, detail="Equipment not found")
|
||||||
|
|
||||||
|
logger.info("Reported equipment failure",
|
||||||
|
equipment_id=str(equipment_id),
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
failure_type=failure_data.get('failureType'))
|
||||||
|
|
||||||
|
# Audit log the failure report
|
||||||
|
await audit_logger.log_event(
|
||||||
|
db_session=db,
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
user_id=current_user.get('user_id'),
|
||||||
|
action=AuditAction.UPDATE.value,
|
||||||
|
resource_type="equipment",
|
||||||
|
resource_id=str(equipment_id),
|
||||||
|
severity=AuditSeverity.WARNING.value,
|
||||||
|
audit_metadata={
|
||||||
|
"action": "report_failure",
|
||||||
|
"failure_type": failure_data.get('failureType'),
|
||||||
|
"severity": failure_data.get('severity')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get notification service from app state
|
||||||
|
notification_service = getattr(request.app.state, 'notification_service', None)
|
||||||
|
|
||||||
|
# Trigger notifications if notification service is available
|
||||||
|
if notification_service:
|
||||||
|
try:
|
||||||
|
await trigger_failure_notifications(
|
||||||
|
notification_service,
|
||||||
|
tenant_id,
|
||||||
|
equipment,
|
||||||
|
failure_data
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send primary notification to equipment support contact if available
|
||||||
|
if equipment.support_contact and equipment.support_contact.get('email'):
|
||||||
|
await send_support_contact_notification(
|
||||||
|
notification_service,
|
||||||
|
tenant_id,
|
||||||
|
equipment,
|
||||||
|
failure_data,
|
||||||
|
equipment.support_contact['email']
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to send notifications", error=str(e), equipment_id=str(equipment_id))
|
||||||
|
# Continue even if notifications fail
|
||||||
|
|
||||||
|
return EquipmentResponse.model_validate(equipment)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error reporting equipment failure",
|
||||||
|
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to report equipment failure")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
route_builder.build_base_route("equipment/{equipment_id}/mark-repaired"),
|
||||||
|
response_model=EquipmentResponse
|
||||||
|
)
|
||||||
|
async def mark_equipment_repaired(
|
||||||
|
repair_data: dict,
|
||||||
|
request: Request,
|
||||||
|
tenant_id: UUID = Path(...),
|
||||||
|
equipment_id: UUID = Path(...),
|
||||||
|
current_user: dict = Depends(get_current_user_dep),
|
||||||
|
production_service: ProductionService = Depends(get_production_service),
|
||||||
|
db = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Mark equipment as repaired and update maintenance records"""
|
||||||
|
try:
|
||||||
|
# Update equipment status and add repair record
|
||||||
|
equipment = await production_service.mark_equipment_repaired(
|
||||||
|
tenant_id,
|
||||||
|
equipment_id,
|
||||||
|
repair_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if not equipment:
|
||||||
|
raise HTTPException(status_code=404, detail="Equipment not found")
|
||||||
|
|
||||||
|
logger.info("Marked equipment as repaired",
|
||||||
|
equipment_id=str(equipment_id),
|
||||||
|
tenant_id=str(tenant_id))
|
||||||
|
|
||||||
|
# Audit log the repair completion
|
||||||
|
await audit_logger.log_event(
|
||||||
|
db_session=db,
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
user_id=current_user.get('user_id'),
|
||||||
|
action=AuditAction.UPDATE.value,
|
||||||
|
resource_type="equipment",
|
||||||
|
resource_id=str(equipment_id),
|
||||||
|
severity=AuditSeverity.INFO.value,
|
||||||
|
audit_metadata={
|
||||||
|
"action": "mark_repaired",
|
||||||
|
"technician": repair_data.get('technicianName'),
|
||||||
|
"cost": repair_data.get('cost')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get notification service from app state
|
||||||
|
notification_service = getattr(request.app.state, 'notification_service', None)
|
||||||
|
|
||||||
|
# Trigger notifications if notification service is available
|
||||||
|
if notification_service:
|
||||||
|
try:
|
||||||
|
# Calculate downtime for notifications
|
||||||
|
last_maintenance_date = equipment.last_maintenance_date or datetime.now(timezone.utc)
|
||||||
|
repair_date_str = repair_data.get('repairDate')
|
||||||
|
if repair_date_str:
|
||||||
|
if 'T' in repair_date_str:
|
||||||
|
repair_date = datetime.fromisoformat(repair_date_str.replace('Z', '+00:00'))
|
||||||
|
else:
|
||||||
|
repair_date = datetime.fromisoformat(f"{repair_date_str}T00:00:00+00:00")
|
||||||
|
else:
|
||||||
|
repair_date = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
downtime_hours = int((repair_date - last_maintenance_date).total_seconds() / 3600)
|
||||||
|
|
||||||
|
# Add downtime to repair_data for notification
|
||||||
|
repair_data_with_downtime = {**repair_data, 'downtime': downtime_hours}
|
||||||
|
|
||||||
|
await trigger_repair_notifications(
|
||||||
|
notification_service,
|
||||||
|
tenant_id,
|
||||||
|
equipment,
|
||||||
|
repair_data_with_downtime
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to send notifications", error=str(e), equipment_id=str(equipment_id))
|
||||||
|
# Continue even if notifications fail
|
||||||
|
|
||||||
|
return EquipmentResponse.model_validate(equipment)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error marking equipment as repaired",
|
||||||
|
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to mark equipment as repaired")
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
route_builder.build_base_route("equipment/{equipment_id}")
|
route_builder.build_base_route("equipment/{equipment_id}")
|
||||||
)
|
)
|
||||||
@@ -277,3 +447,134 @@ async def delete_equipment(
|
|||||||
logger.error("Error deleting equipment",
|
logger.error("Error deleting equipment",
|
||||||
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
|
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
|
||||||
raise HTTPException(status_code=500, detail="Failed to delete equipment")
|
raise HTTPException(status_code=500, detail="Failed to delete equipment")
|
||||||
|
|
||||||
|
|
||||||
|
# Helper functions for notifications
|
||||||
|
async def trigger_failure_notifications(notification_service: any, tenant_id: UUID, equipment: any, failure_data: dict):
|
||||||
|
"""Trigger failure notifications via email - sends to bakery managers"""
|
||||||
|
try:
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Load template from file
|
||||||
|
template_dir = Path(__file__).parent.parent.parent / "notification" / "app" / "templates"
|
||||||
|
env = Environment(loader=FileSystemLoader(str(template_dir)))
|
||||||
|
template = env.get_template('equipment_failure_email.html')
|
||||||
|
|
||||||
|
# Prepare template variables
|
||||||
|
template_vars = {
|
||||||
|
"equipment_name": equipment.name,
|
||||||
|
"equipment_type": equipment.type.value if hasattr(equipment.type, 'value') else equipment.type,
|
||||||
|
"equipment_model": equipment.model or "N/A",
|
||||||
|
"equipment_serial_number": equipment.serial_number or "N/A",
|
||||||
|
"equipment_location": equipment.location or "N/A",
|
||||||
|
"failure_type": failure_data.get('failureType', 'Unknown'),
|
||||||
|
"severity": failure_data.get('severity', 'high'),
|
||||||
|
"description": failure_data.get('description', ''),
|
||||||
|
"reported_time": datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'),
|
||||||
|
"estimated_impact": "SÍ - Afecta producción" if failure_data.get('estimatedImpact') else "NO - Sin impacto en producción",
|
||||||
|
"support_contact": equipment.support_contact or {},
|
||||||
|
"equipment_link": f"https://app.bakeryia.com/equipment/{equipment.id}",
|
||||||
|
"bakery_name": "BakeryIA",
|
||||||
|
"current_year": datetime.now().year
|
||||||
|
}
|
||||||
|
|
||||||
|
html_content = template.render(**template_vars)
|
||||||
|
|
||||||
|
# Send via notification service (which will handle the actual email sending)
|
||||||
|
# This is a simplified approach - in production you'd want to get manager emails from DB
|
||||||
|
logger.info("Failure notifications triggered (template rendered)",
|
||||||
|
equipment_id=str(equipment.id),
|
||||||
|
tenant_id=str(tenant_id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error triggering failure notifications",
|
||||||
|
error=str(e), equipment_id=str(equipment.id), tenant_id=str(tenant_id))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_repair_notifications(notification_service: any, tenant_id: UUID, equipment: any, repair_data: dict):
|
||||||
|
"""Trigger repair completion notifications via email - sends to bakery managers"""
|
||||||
|
try:
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Load template from file
|
||||||
|
template_dir = Path(__file__).parent.parent.parent / "notification" / "app" / "templates"
|
||||||
|
env = Environment(loader=FileSystemLoader(str(template_dir)))
|
||||||
|
template = env.get_template('equipment_repaired_email.html')
|
||||||
|
|
||||||
|
# Prepare template variables
|
||||||
|
template_vars = {
|
||||||
|
"equipment_name": equipment.name,
|
||||||
|
"equipment_type": equipment.type.value if hasattr(equipment.type, 'value') else equipment.type,
|
||||||
|
"equipment_model": equipment.model or "N/A",
|
||||||
|
"equipment_location": equipment.location or "N/A",
|
||||||
|
"repair_date": repair_data.get('repairDate', datetime.now(timezone.utc).strftime('%Y-%m-%d')),
|
||||||
|
"technician_name": repair_data.get('technicianName', 'Unknown'),
|
||||||
|
"repair_description": repair_data.get('repairDescription', ''),
|
||||||
|
"parts_replaced": repair_data.get('partsReplaced', []),
|
||||||
|
"cost": repair_data.get('cost', 0),
|
||||||
|
"downtime_hours": repair_data.get('downtime', 0),
|
||||||
|
"test_results": repair_data.get('testResults', False),
|
||||||
|
"equipment_link": f"https://app.bakeryia.com/equipment/{equipment.id}",
|
||||||
|
"bakery_name": "BakeryIA",
|
||||||
|
"current_year": datetime.now().year
|
||||||
|
}
|
||||||
|
|
||||||
|
html_content = template.render(**template_vars)
|
||||||
|
|
||||||
|
# Send via notification service
|
||||||
|
logger.info("Repair notifications triggered (template rendered)",
|
||||||
|
equipment_id=str(equipment.id),
|
||||||
|
tenant_id=str(tenant_id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error triggering repair notifications",
|
||||||
|
error=str(e), equipment_id=str(equipment.id), tenant_id=str(tenant_id))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def send_support_contact_notification(notification_service: any, tenant_id: UUID, equipment: any, failure_data: dict, support_email: str):
|
||||||
|
"""Send direct notification to equipment support contact for repair request"""
|
||||||
|
try:
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Load template from file
|
||||||
|
template_dir = Path(__file__).parent.parent.parent / "notification" / "app" / "templates"
|
||||||
|
env = Environment(loader=FileSystemLoader(str(template_dir)))
|
||||||
|
template = env.get_template('equipment_failure_email.html')
|
||||||
|
|
||||||
|
# Prepare template variables
|
||||||
|
template_vars = {
|
||||||
|
"equipment_name": equipment.name,
|
||||||
|
"equipment_type": equipment.type.value if hasattr(equipment.type, 'value') else equipment.type,
|
||||||
|
"equipment_model": equipment.model or "N/A",
|
||||||
|
"equipment_serial_number": equipment.serial_number or "N/A",
|
||||||
|
"equipment_location": equipment.location or "N/A",
|
||||||
|
"failure_type": failure_data.get('failureType', 'Unknown'),
|
||||||
|
"severity": failure_data.get('severity', 'high'),
|
||||||
|
"description": failure_data.get('description', ''),
|
||||||
|
"reported_time": datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'),
|
||||||
|
"estimated_impact": "SÍ - Afecta producción" if failure_data.get('estimatedImpact') else "NO - Sin impacto en producción",
|
||||||
|
"support_contact": equipment.support_contact or {},
|
||||||
|
"equipment_link": f"https://app.bakeryia.com/equipment/{equipment.id}",
|
||||||
|
"bakery_name": "BakeryIA",
|
||||||
|
"current_year": datetime.now().year
|
||||||
|
}
|
||||||
|
|
||||||
|
html_content = template.render(**template_vars)
|
||||||
|
|
||||||
|
# TODO: Actually send email via notification service
|
||||||
|
# For now, just log that we would send to the support email
|
||||||
|
logger.info("Support contact notification prepared (would send to support)",
|
||||||
|
equipment_id=str(equipment.id),
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
support_email=support_email,
|
||||||
|
subject=f"🚨 URGENTE: Fallo de Equipo - {equipment.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error sending support contact notification",
|
||||||
|
error=str(e), equipment_id=str(equipment.id), tenant_id=str(tenant_id))
|
||||||
|
raise
|
||||||
|
|||||||
@@ -611,6 +611,9 @@ class Equipment(Base):
|
|||||||
# Notes
|
# Notes
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Support contact information
|
||||||
|
support_contact = Column(JSON, nullable=True)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
@@ -655,6 +658,7 @@ class Equipment(Base):
|
|||||||
"supports_remote_control": self.supports_remote_control,
|
"supports_remote_control": self.supports_remote_control,
|
||||||
"is_active": self.is_active,
|
"is_active": self.is_active,
|
||||||
"notes": self.notes,
|
"notes": self.notes,
|
||||||
|
"support_contact": self.support_contact,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,21 @@ class EquipmentCreate(BaseModel):
|
|||||||
# Notes
|
# Notes
|
||||||
notes: Optional[str] = Field(None, description="Additional notes")
|
notes: Optional[str] = Field(None, description="Additional notes")
|
||||||
|
|
||||||
|
# Support contact information
|
||||||
|
support_contact: Optional[dict] = Field(
|
||||||
|
None,
|
||||||
|
description="Support contact information for equipment maintenance",
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"email": "support@ovenfactory.com",
|
||||||
|
"phone": "+1-800-555-1234",
|
||||||
|
"company": "OvenTech Support",
|
||||||
|
"contract_number": "SUP-2023-001",
|
||||||
|
"response_time_sla": 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
@@ -157,6 +172,9 @@ class EquipmentUpdate(BaseModel):
|
|||||||
# Notes
|
# Notes
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
# Support contact information
|
||||||
|
support_contact: Optional[dict] = None
|
||||||
|
|
||||||
# Status flag
|
# Status flag
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
@@ -228,6 +246,9 @@ class EquipmentResponse(BaseModel):
|
|||||||
is_active: bool
|
is_active: bool
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
# Support contact information
|
||||||
|
support_contact: Optional[dict] = None
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -49,6 +49,152 @@ class ProductionService:
|
|||||||
self.recipes_client = RecipesServiceClient(config)
|
self.recipes_client = RecipesServiceClient(config)
|
||||||
self.sales_client = get_sales_client(config, "production")
|
self.sales_client = get_sales_client(config, "production")
|
||||||
|
|
||||||
|
async def report_equipment_failure(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
equipment_id: UUID,
|
||||||
|
failure_data: dict
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Report equipment failure and update status to 'down'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
equipment_id: Equipment ID
|
||||||
|
failure_data: Failure report data including type, severity, description, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated equipment object
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self.database_manager.get_session() as session:
|
||||||
|
from app.repositories.equipment_repository import EquipmentRepository
|
||||||
|
equipment_repo = EquipmentRepository(session)
|
||||||
|
|
||||||
|
# Get current equipment
|
||||||
|
equipment = await equipment_repo.get_equipment_by_id(tenant_id, equipment_id)
|
||||||
|
if not equipment:
|
||||||
|
raise ValueError("Equipment not found")
|
||||||
|
|
||||||
|
# Prepare update data - only use fields that exist in Equipment model
|
||||||
|
from app.models.production import EquipmentStatus
|
||||||
|
|
||||||
|
update_data = {
|
||||||
|
"status": EquipmentStatus.DOWN,
|
||||||
|
"is_active": False,
|
||||||
|
"last_maintenance_date": datetime.now(timezone.utc),
|
||||||
|
"efficiency_percentage": 0, # Set efficiency to 0 when failed
|
||||||
|
"uptime_percentage": max(0, (equipment.uptime_percentage or 0) - 5), # Reduce uptime
|
||||||
|
"notes": f"FAILURE REPORTED: {failure_data.get('failureType')} - {failure_data.get('description')}\nSeverity: {failure_data.get('severity')}\nReported: {datetime.now(timezone.utc).isoformat()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update equipment
|
||||||
|
updated_equipment = await equipment_repo.update_equipment(
|
||||||
|
equipment_id,
|
||||||
|
update_data
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Reported equipment failure",
|
||||||
|
equipment_id=str(equipment_id),
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
failure_type=failure_data.get('failureType'),
|
||||||
|
severity=failure_data.get('severity'))
|
||||||
|
|
||||||
|
return updated_equipment
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error reporting equipment failure",
|
||||||
|
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def mark_equipment_repaired(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
equipment_id: UUID,
|
||||||
|
repair_data: dict
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Mark equipment as repaired and update maintenance records
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
equipment_id: Equipment ID
|
||||||
|
repair_data: Repair data including technician, description, parts, cost, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated equipment object
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self.database_manager.get_session() as session:
|
||||||
|
from app.repositories.equipment_repository import EquipmentRepository
|
||||||
|
equipment_repo = EquipmentRepository(session)
|
||||||
|
|
||||||
|
# Get current equipment
|
||||||
|
equipment = await equipment_repo.get_equipment_by_id(tenant_id, equipment_id)
|
||||||
|
if not equipment:
|
||||||
|
raise ValueError("Equipment not found")
|
||||||
|
|
||||||
|
# Calculate downtime
|
||||||
|
last_maintenance_date = equipment.last_maintenance_date or datetime.now(timezone.utc)
|
||||||
|
repair_date_str = repair_data.get('repairDate')
|
||||||
|
if repair_date_str:
|
||||||
|
# Handle both ISO format and date-only format
|
||||||
|
if 'T' in repair_date_str:
|
||||||
|
repair_date = datetime.fromisoformat(repair_date_str.replace('Z', '+00:00'))
|
||||||
|
else:
|
||||||
|
repair_date = datetime.fromisoformat(f"{repair_date_str}T00:00:00+00:00")
|
||||||
|
else:
|
||||||
|
repair_date = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
downtime_hours = int((repair_date - last_maintenance_date).total_seconds() / 3600)
|
||||||
|
|
||||||
|
# Prepare update data - only use fields that exist in Equipment model
|
||||||
|
from app.models.production import EquipmentStatus
|
||||||
|
|
||||||
|
parts_list = ", ".join(repair_data.get('partsReplaced', [])) if repair_data.get('partsReplaced') else "None"
|
||||||
|
update_data = {
|
||||||
|
"status": EquipmentStatus.OPERATIONAL,
|
||||||
|
"is_active": True,
|
||||||
|
"last_maintenance_date": repair_date,
|
||||||
|
"efficiency_percentage": 100, # Reset efficiency after repair
|
||||||
|
"uptime_percentage": min(100, (equipment.uptime_percentage or 0) + 2), # Increase uptime
|
||||||
|
"notes": f"REPAIR COMPLETED: {repair_data.get('repairDescription')}\nTechnician: {repair_data.get('technicianName')}\nCost: €{repair_data.get('cost', 0)}\nDowntime: {downtime_hours} hours\nParts: {parts_list}\nDate: {repair_date.isoformat()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update equipment
|
||||||
|
updated_equipment = await equipment_repo.update_equipment(
|
||||||
|
equipment_id,
|
||||||
|
update_data
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Marked equipment as repaired",
|
||||||
|
equipment_id=str(equipment_id),
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
technician=repair_data.get('technicianName'),
|
||||||
|
cost=repair_data.get('cost', 0),
|
||||||
|
downtime_hours=downtime_hours)
|
||||||
|
|
||||||
|
return updated_equipment
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error marking equipment as repaired",
|
||||||
|
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# SUSTAINABILITY / WASTE ANALYTICS
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
async def get_waste_analytics(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
start_date: datetime,
|
||||||
|
end_date: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get waste analytics for the specified period"""
|
||||||
|
# TODO: Implement waste analytics
|
||||||
|
return {}
|
||||||
|
|
||||||
async def calculate_daily_requirements(
|
async def calculate_daily_requirements(
|
||||||
self,
|
self,
|
||||||
tenant_id: UUID,
|
tenant_id: UUID,
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ def upgrade() -> None:
|
|||||||
sa.Column('target_temperature', sa.Float(), nullable=True),
|
sa.Column('target_temperature', sa.Float(), nullable=True),
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
sa.Column('notes', sa.Text(), nullable=True),
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('support_contact', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
|||||||
Reference in New Issue
Block a user