import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { useWorkOrderDetails, useWorkOrderMutations } from '../hooks/useWorkOrder'; import { useWorkflow } from '../hooks/useWorkflow'; import { useFrappeFieldBehavior } from '../hooks/useFrappeFieldBehavior'; import { setCurrentUser } from '../services/workflowService'; import type { WorkflowTransition } from '../services/workflowService'; import { FaArrowLeft, FaSave, FaEdit, FaLink, FaSearch, FaSpinner, FaExclamationTriangle, FaInfoCircle, FaPrint, FaPlus, FaTrash, FaCheckCircle, FaTimesCircle, FaUpload, FaFile, FaTimes, FaExternalLinkAlt, FaHistory, FaChevronDown, FaChevronUp, FaUser, FaClock, FaSync, FaBan, FaClipboardList } from 'react-icons/fa'; import type { CreateWorkOrderData } from '../services/workOrderService'; import { technicalReportHasGuideCompleted } from '../utils/troubleshootGuideMarkers'; import { toast, ToastContainer, Bounce } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import LinkField from '../components/LinkField'; import WorkflowActions from '../components/WorkflowActions'; import apiService from '../services/apiService'; import API_CONFIG from '../config/api'; import CommentSection from '../components/CommentSection'; import DeleteRequestButton from '../components/DeleteRequestButton'; import type { DeleteStatus } from '../services/deleteRequestService'; import issueService from '../services/issueService'; // Print Format Configuration const PRINT_FORMAT_NAME = 'Service_Report'; // Change this if your print format has a different name // ============== DATE/TIME HELPER FUNCTIONS ============== /** * Get today's date in YYYY-MM-DD format */ const getTodayDate = (): string => { return new Date().toISOString().split('T')[0]; }; /** * Get current datetime in YYYY-MM-DDTHH:MM format (for datetime-local input) */ const getCurrentDateTime = (): string => { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}`; }; /** * Get current datetime in Frappe format (YYYY-MM-DD HH:MM:SS) */ const getCurrentDateTimeForFrappe = (): string => { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; }; /** * Format datetime from Frappe format to datetime-local input format * Input: "2024-01-15 14:30:00" or "2024-01-15" * Output: "2024-01-15T14:30" */ const formatDateTimeForInput = (dateStr: string | null | undefined): string => { if (!dateStr) return ''; // If it's just a date (YYYY-MM-DD), return as-is with default time if (dateStr.length === 10) { return `${dateStr}T00:00`; } // If it contains space (Frappe format: YYYY-MM-DD HH:MM:SS) if (dateStr.includes(' ')) { const [datePart, timePart] = dateStr.split(' '); const timeWithoutSeconds = timePart.substring(0, 5); // Take only HH:MM return `${datePart}T${timeWithoutSeconds}`; } // If already in ISO format with T if (dateStr.includes('T')) { return dateStr.substring(0, 16); // Take YYYY-MM-DDTHH:MM } return dateStr; }; /** * Format datetime-local input value to Frappe format * Input: "2024-01-15T14:30" * Output: "2024-01-15 14:30:00" */ const formatDateTimeForFrappe = (dateTimeLocalValue: string): string => { if (!dateTimeLocalValue) return ''; // datetime-local format: YYYY-MM-DDTHH:MM // Frappe format: YYYY-MM-DD HH:MM:SS if (dateTimeLocalValue.includes('T')) { return dateTimeLocalValue.replace('T', ' ') + ':00'; } return dateTimeLocalValue; }; /** * Format datetime for display (human readable) */ const formatDateTimeForDisplay = (dateStr: string | null | undefined): string => { if (!dateStr) return '-'; try { const date = new Date(dateStr.replace(' ', 'T')); return date.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false // 24-hour format }); } catch { return dateStr; } }; // Helper to add days to a date const addDays = (dateStr: string, days: number): string => { if (!dateStr) return ''; // Handle datetime format - extract just the date part const datePart = dateStr.split('T')[0].split(' ')[0]; const date = new Date(datePart); date.setDate(date.getDate() + days); return date.toISOString().split('T')[0]; }; /** Map Issue opening_date / opening_time to datetime-local value for failure_date. */ const buildFailureDateFromIssueOpening = ( openingDate?: string | null, openingTime?: string | null ): string => { const now = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); const fallback = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; if (!openingDate?.trim()) return fallback; const datePart = openingDate.split(' ')[0].split('T')[0]; let timePart = '00:00'; if (openingTime && String(openingTime).trim().length >= 5) { timePart = String(openingTime).substring(0, 5); } return `${datePart}T${timePart}`; }; /** Work Order `custom_priority_` select options (Asset Lite custom field). */ const WORK_ORDER_CUSTOM_PRIORITIES = ['Normal', 'Medium', 'Urgent'] as const; type WorkOrderCustomPriority = (typeof WORK_ORDER_CUSTOM_PRIORITIES)[number]; /** * Issue.priority links to "Issue Priority" (typically Low / Medium / High). * Work_Order.custom_priority_ only allows Normal / Medium / Urgent. */ const mapIssuePriorityToWorkOrderCustomPriority = ( issuePriority: string | null | undefined ): WorkOrderCustomPriority => { if (!issuePriority?.trim()) return 'Normal'; const raw = issuePriority.trim(); if ((WORK_ORDER_CUSTOM_PRIORITIES as readonly string[]).includes(raw)) { return raw as WorkOrderCustomPriority; } const norm = raw.toLowerCase(); if (norm === 'low') return 'Normal'; if (norm === 'medium') return 'Medium'; if (norm === 'high' || norm === 'urgent' || norm === 'critical') return 'Urgent'; return 'Normal'; }; // ============== END DATE/TIME HELPER FUNCTIONS ============== const WorkOrderDetail: React.FC = () => { const { t } = useTranslation(); const { workOrderName } = useParams<{ workOrderName: string }>(); const navigate = useNavigate(); const location = useLocation(); const [searchParams] = useSearchParams(); const duplicateFromWorkOrder = searchParams.get('duplicate'); const assetIdFromParams = searchParams.get('asset'); const isCreatingFromAsset = !!assetIdFromParams; const isNewWorkOrder = workOrderName === 'new'; const isDuplicating = isNewWorkOrder && !!duplicateFromWorkOrder; const fromIssueName = searchParams.get('from_issue'); const isCreatingFromIssue = isNewWorkOrder && !!fromIssueName && !isDuplicating; const [issuePrefillResolved, setIssuePrefillResolved] = useState(!isCreatingFromIssue); /** * Open Service Report print format in a new window * Uses Frappe's built-in print view with trigger_print to auto-open print dialog */ const handlePrintServiceReport = () => { if (!workOrderName || isNewWorkOrder) return; // Construct the print URL using Frappe's printview const baseUrl = API_CONFIG.BASE_URL || ''; const printUrl = `${baseUrl}/printview?doctype=Work_Order&name=${encodeURIComponent(workOrderName)}&format=${encodeURIComponent(PRINT_FORMAT_NAME)}&trigger_print=1`; // Open in new window/tab const printWindow = window.open(printUrl, '_blank'); // Fallback: If popup is blocked, show message if (!printWindow) { toast.warning(t('workOrders.detail.pleaseAllowPopupsServiceReport'), { position: "top-right", autoClose: 5000, icon: }); } }; const [isMaintenanceManager, setIsMaintenanceManager] = useState(false); // User roles state for workflow transition filtering const [userRoles, setUserRoles] = useState([]); const [rolesLoaded, setRolesLoaded] = useState(false); const [currentUser, setCurrentUser] = useState(''); // State for multi-technician selection modal const [showTechnicianModal, setShowTechnicianModal] = useState(false); const [selectedAdditionalTechnicians, setSelectedAdditionalTechnicians] = useState([]); const [availableTechnicians, setAvailableTechnicians] = useState<{name: string, full_name?: string}[]>([]); const [technicianSearchQuery, setTechnicianSearchQuery] = useState(''); const [loadingTechnicians, setLoadingTechnicians] = useState(false); // State for technician selection confirmation const [showTechnicianConfirm, setShowTechnicianConfirm] = useState(false); // State to track why Apply button is hidden (for Work Control role) const [applyHiddenReason, setApplyHiddenReason] = useState(null); const [pendingTechnicians, setPendingTechnicians] = useState([]); // State for duplicate work order detection const [showDuplicateWarning, setShowDuplicateWarning] = useState(false); const [duplicateWorkOrders, setDuplicateWorkOrders] = useState<{name: string, workflow_state: string, creation: string}[]>([]); const [duplicateCheckType, setDuplicateCheckType] = useState<'asset' | 'room'>('room'); const [checkingDuplicates, setCheckingDuplicates] = useState(false); // State for asset mandatory check based on location filters const [assetIsMandatory, setAssetIsMandatory] = useState(false); const [assetFilterCount, setAssetFilterCount] = useState(0); const { workOrder, loading, error, refetch } = useWorkOrderDetails( isDuplicating ? duplicateFromWorkOrder : (isNewWorkOrder ? null : workOrderName || null) ); const { createWorkOrder, updateWorkOrder, loading: saving } = useWorkOrderMutations(); useEffect(() => { if (isNewWorkOrder || !workOrderName) return; const st = location.state as { troubleshootGuideJustCompleted?: boolean } | undefined; if (!st?.troubleshootGuideJustCompleted) return; void refetch(); navigate( { pathname: location.pathname, search: location.search, hash: location.hash }, { replace: true, state: {} }, ); }, [ location.state, location.pathname, location.search, location.hash, navigate, refetch, isNewWorkOrder, workOrderName, ]); const [isEditing, setIsEditing] = useState(isNewWorkOrder); const [isLoadingAsset, setIsLoadingAsset] = useState(false); const [confirmAction, setConfirmAction] = useState<{ action: string; nextState: string } | null>(null); // ✅ Roles allowed to delete/cancel work orders const DELETE_ALLOWED_ROLES = [ 'System Manager', 'Work Control', 'Contractor Supervisor', 'Contractor Manager', 'Maintenance Manager', 'Cluster Manager', 'Contractor Engineer', 'Quality Supervisor', 'Technician' ]; // ✅ State for role-based delete permission const [canDelete, setCanDelete] = useState(false); // Delete request flow status const [woDeleteStatus, setWoDeleteStatus] = useState(null); const deleteStatusJustUpdated = React.useRef(false); // ✅ State for cancel operation const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [cancelling, setCancelling] = useState(false); // ✅ State for delete operation const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // State for filtered managers and technicians const [filteredManagers, setFilteredManagers] = useState([]); const [filteredTechnicians, setFilteredTechnicians] = useState([]); const [filteredEngineers, setFilteredEngineers] = useState([]); // Track initial values to detect changes for "Back To Controller" button and restore on revert const [hasLoadedInitialData, setHasLoadedInitialData] = useState(false); const [initialWorkOrderType, setInitialWorkOrderType] = useState(''); const [initialSupervisor, setInitialSupervisor] = useState(''); const [initialEngineer, setInitialEngineer] = useState(''); const [initialTechnician, setInitialTechnician] = useState(''); // State for cascading location filters const [filteredDepartmentsForBuilding, setFilteredDepartmentsForBuilding] = useState([]); const [filteredRoomNosForDepartment, setFilteredRoomNosForDepartment] = useState([]); const [loadingLocationData, setLoadingLocationData] = useState(false); // State for file upload const [isUploading, setIsUploading] = useState(false); // State for room number search in dropdown const [roomSearchQuery, setRoomSearchQuery] = useState(''); const [showRoomDropdown, setShowRoomDropdown] = useState(false); // Stock Item interface for child table interface StockItem { item_code: string; item_name?: string; warehouse: string; consumed_quantity: number; valuation_rate: number; custom_available_stock: number; total_value: number; } // Audit Log / Version interface interface VersionChange { field: string; oldValue: any; newValue: any; } interface AuditLogEntry { name: string; owner: string; creation: string; changes: VersionChange[]; added: any[]; removed: any[]; rowChanged: any[]; } // State for audit logs const [auditLogs, setAuditLogs] = useState([]); const [auditLogsLoading, setAuditLogsLoading] = useState(false); // const [auditLogsExpanded, setAuditLogsExpanded] = useState(true); const [auditLogsExpanded, setAuditLogsExpanded] = useState(false); const [showAllLogs, setShowAllLogs] = useState(false); // ============== FEEDBACK STATE ============== const [feedbackData, setFeedbackData] = useState(null); const [feedbackLoading, setFeedbackLoading] = useState(false); const [showFeedbackModal, setShowFeedbackModal] = useState(false); const [feedbackMode, setFeedbackMode] = useState<'give' | 'view' | 'edit'>('give'); const [feedbackRatings, setFeedbackRatings] = useState<{parameter: string; rating: number; feedback: string}[]>([ { parameter: 'Quality of Service', rating: 0, feedback: '' }, { parameter: 'Timeliness', rating: 0, feedback: '' }, { parameter: 'Professionalism', rating: 0, feedback: '' }, { parameter: 'Communication', rating: 0, feedback: '' }, ]); const [feedbackSubmitting, setFeedbackSubmitting] = useState(false); const [currentUserFullName, setCurrentUserFullName] = useState(''); const [formData, setFormData] = useState({ company: 'King Fahad Specialist Hospital - Dammam', work_order_type: '', custom_type:'Corrective', custom_civil_work_category: '', asset: '', asset_name: '', description: '', repair_status: 'Open', workflow_state: 'Draft', department: '', custom_priority_: 'Normal', asset_type: 'Non Biomedical', // Default to Non Biomedical manufacturer: '', supplier: '', serial_number: '', model: '', custom_site_contractor: '', custom_subcontractor: '', failure_date: isNewWorkOrder ? getCurrentDateTime() : '', // ✅ Changed to DateTime custom_deadline_date: '', completion_date: '', // ✅ DateTime field first_responded_on: '', // ✅ DateTime field actions_performed: '', stock_consumption: 0, stock_items: [], // Fields for workflow conditions site_name: '', need_procurement: 0, custom_assign_to_contractor: '', assigned_technician: '', custom_assigned_engineer:'', docstatus: 0, // New fields custom_assigned_supervisor: '', total_hours_spent: 0, custom_pending_reason: '', total_repair_cost: 0, custom_travel_hour: '', // Service Agreement fields custom_service_agreement: '', custom_service_coverage: '', custom_start_date: '', custom_end_date: '', custom_total_amount: 0, // New fields added custom_location: '', custom_extension_no: '', custom_attachment: '', custom_attachment_on_close: '', custom_add_technicians: '', // Additional new fields inspection: '', custom_department_no: '', custom_reason: '', custom_technical_department: '', custom_room_no: '', custom_building: '', issue: '', // For Frappe field behavior evaluation __islocal: false, }); // Check if asset type is Non Biomedical const isNonBiomedical = formData.asset_type === 'Non Biomedical'; // ✅ Derived state for docstatus logic (like ERPNext) // docstatus 0 = Draft (workflow_state !== 'Closed' && workflow_state !== 'Cancelled') // docstatus 1 = Submitted (workflow_state === 'Closed') // docstatus 2 = Cancelled (workflow_state === 'Cancelled') const getDocStatus = useCallback((): number => { const workflowState = workOrder?.workflow_state || formData.workflow_state || 'Draft'; if (workflowState === 'Cancelled') return 2; if (workflowState === 'Closed') return 1; return workOrder?.docstatus || formData.docstatus || 0; }, [workOrder?.workflow_state, formData.workflow_state, workOrder?.docstatus, formData.docstatus]); const docStatus = getDocStatus(); const isSubmitted = docStatus === 1; // Closed state const isCancelled = docStatus === 2; // Cancelled state const isDraft = docStatus === 0; // Draft/Open states // ✅ Check if form should be read-only (submitted or cancelled) const isFormReadOnly = isSubmitted || isCancelled; // ✅ Lock form if any delete request is in progress const isDeletePending = !!woDeleteStatus; const isWorkOrderOwner = !!(currentUser && workOrder?.owner && currentUser === workOrder.owner); /** * Get date N days ago in YYYY-MM-DD format */ const getDateDaysAgo = (days: number): string => { const date = new Date(); date.setDate(date.getDate() - (days - 1)); // -1 because 1 means today only return date.toISOString().split('T')[0]; }; // ============== DUPLICATE WORK ORDER DETECTION ============== /** * Fetch the custom_days configuration from Work Order Settings */ const fetchDaysConfig = async (): Promise => { try { const response = await apiService.apiCall( `/api/resource/Work Order Settings/Work Order Settings` ); if (response?.data?.custom_days) { return parseInt(response.data.custom_days) || 1; } return 1; } catch (err) { console.error('Error fetching days config:', err); return 1; } }; /** * Check for duplicate work orders by Work Order Type and Room No */ const checkDuplicatesByRoomNo = useCallback(async (woType: string, roomNo: string) => { if (!woType || !roomNo || !isNewWorkOrder) return; setCheckingDuplicates(true); try { const daysConfig = await fetchDaysConfig(); const fromDate = getDateDaysAgo(daysConfig); const filters = [ ['work_order_type', '=', woType], ['custom_room_no', '=', roomNo], ['workflow_state', 'not in', ['Approved', 'Closed', 'Cancelled']], ['creation', '>=', `${fromDate} 00:00:00`] ]; const response = await apiService.apiCall( `/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=["name","workflow_state","creation"]&limit=10` ); if (response?.data && response.data.length > 0) { setDuplicateWorkOrders(response.data); setDuplicateCheckType('room'); setShowDuplicateWarning(true); } } catch (err) { console.error('Error checking duplicates by Room No:', err); } finally { setCheckingDuplicates(false); } }, [isNewWorkOrder]); /** * Check for duplicate work orders by Asset ID */ const checkDuplicatesByAsset = useCallback(async (assetId: string) => { if (!assetId || !isNewWorkOrder) return; setCheckingDuplicates(true); try { const filters = [ ['asset', '=', assetId], ['workflow_state', 'not in', ['Approved', 'Closed', 'Cancelled']] ]; const response = await apiService.apiCall( `/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=["name","workflow_state","creation"]&limit=10` ); if (response?.data && response.data.length > 0) { setDuplicateWorkOrders(response.data); setDuplicateCheckType('asset'); setShowDuplicateWarning(true); } } catch (err) { console.error('Error checking duplicates by Asset:', err); } finally { setCheckingDuplicates(false); } }, [isNewWorkOrder]); const handleProceedWithDuplicate = () => { setShowDuplicateWarning(false); setDuplicateWorkOrders([]); }; const handleCancelDuplicate = () => { setShowDuplicateWarning(false); setDuplicateWorkOrders([]); if (duplicateCheckType === 'asset') { setFormData(prev => ({ ...prev, asset: '', asset_name: '' })); } else { setFormData(prev => ({ ...prev, work_order_type: '', custom_room_no: '' })); } }; // Effect to check duplicates when Work Order Type and Room No are both set (only if no Asset) useEffect(() => { const woType = formData.work_order_type; const roomNo = formData.custom_room_no; if ( isNewWorkOrder && !formData.asset && woType && // This acts as a type guard - ensures it's truthy (not undefined or empty) roomNo ) { const timer = setTimeout(() => { checkDuplicatesByRoomNo(woType, roomNo); // Now TypeScript knows these are strings }, 500); return () => clearTimeout(timer); } }, [isNewWorkOrder, formData.asset, formData.work_order_type, formData.custom_room_no, checkDuplicatesByRoomNo]); // Effect to check duplicates when Asset is selected useEffect(() => { const assetId = formData.asset; if (isNewWorkOrder && assetId) { // This acts as a type guard const timer = setTimeout(() => { checkDuplicatesByAsset(assetId); // Now TypeScript knows this is a string }, 500); return () => clearTimeout(timer); } }, [isNewWorkOrder, formData.asset, checkDuplicatesByAsset]); // Effect to check asset count based on location filters and set mandatory if exactly 1 exists useEffect(() => { const checkAssetCount = async () => { // Only run when no asset is already set and at least one location filter exists if (formData.asset) { setAssetIsMandatory(false); setAssetFilterCount(0); return; } const hasLocationFilter = formData.custom_building || formData.department || formData.custom_room_no; if (!hasLocationFilter) { setAssetIsMandatory(false); setAssetFilterCount(0); return; } try { const filterArray: [string, string, string][] = [['docstatus', '=', '1']]; if (formData.company) filterArray.push(['company', '=', formData.company]); if (formData.custom_building) filterArray.push(['custom_building', '=', formData.custom_building]); if (formData.department) filterArray.push(['department', '=', formData.department]); if (formData.custom_room_no) filterArray.push(['custom_room_number', '=', formData.custom_room_no]); const response = await apiService.apiCall( `/api/resource/Asset?filters=${encodeURIComponent(JSON.stringify(filterArray))}&fields=["name"]&limit=1000` ); const count = response?.data?.length || 0; setAssetFilterCount(count); // Make mandatory if at least 1 asset exists for selected location setAssetIsMandatory(count >= 1); // Auto-select only when ALL three location fields are filled and exactly 1 match const allLocationFieldsFilled = !!(formData.custom_building && formData.department && formData.custom_room_no); if (count === 1 && response.data[0]?.name && !formData.asset && allLocationFieldsFilled) { handleAssetIdChange(response.data[0].name); toast.info(`Asset auto-selected: ${response.data[0].name}`, { position: "top-right", autoClose: 3000, icon: }); } } catch (err) { console.error('Error checking asset count:', err); setAssetIsMandatory(false); setAssetFilterCount(0); } }; const timer = setTimeout(checkAssetCount, 500); return () => clearTimeout(timer); }, [ formData.asset, formData.company, formData.custom_building, formData.department, formData.custom_room_no ]); const handleRoomNoChange = (val: string) => { setFormData(prev => ({ ...prev, custom_room_no: val })); }; // ============== AUTO EDIT MODE FOR "Repair InProgress" ============== /** * Auto-enable edit mode when workflow_state is "Repair InProgress" */ useEffect(() => { const hasTechnicianRole = userRoles.includes('Technician'); if (!isNewWorkOrder && workOrder?.workflow_state === 'Repair InProgress' && !isDeletePending && hasTechnicianRole) { setIsEditing(true); } }, [isNewWorkOrder, workOrder?.workflow_state, isDeletePending, userRoles]); // ============== AUTO SET FIRST RESPONDED ON ============== /** * Auto-set first_responded_on when workflow_state changes to "Repair InProgress" * This mirrors Frappe's client script behavior * ✅ Updated to capture both date and time */ useEffect(() => { const autoSetFirstRespondedOn = async () => { if ( !isNewWorkOrder && workOrderName && workOrder?.workflow_state === 'Repair InProgress' && (!workOrder?.first_responded_on || workOrder.first_responded_on === '') ) { // ✅ Get current date AND time in Frappe format const nowDateTime = getCurrentDateTimeForFrappe(); try { // Update the document with first_responded_on await updateWorkOrder(workOrderName, { first_responded_on: nowDateTime }); // Update local form data (use input format for display) setFormData(prev => ({ ...prev, first_responded_on: formatDateTimeForInput(nowDateTime) })); toast.info(t('workOrders.detail.firstRespondedOnSetTo', { datetime: formatDateTimeForDisplay(nowDateTime) }), { position: "top-right", autoClose: 3000, icon: }); // Refetch to get updated data refetch(); // Refresh audit logs immediately setTimeout(() => { fetchAuditLogs(); }, 500); console.log('Auto-set first_responded_on to:', nowDateTime); } catch (err) { console.error('Error auto-setting first_responded_on:', err); } } }; autoSetFirstRespondedOn(); }, [isNewWorkOrder, workOrderName, workOrder?.workflow_state, workOrder?.first_responded_on]); // ============== AUTO SET COMPLETION DATE ============== /** * Auto-set completion_date when workflow_state changes to "Closed" * ✅ Captures both date and time */ useEffect(() => { const autoSetCompletionDate = async () => { if ( !isNewWorkOrder && workOrderName && // workOrder?.workflow_state === 'Approved' && workOrder?.workflow_state === 'Closed' && (!workOrder?.completion_date || workOrder.completion_date === '') ) { // ✅ Get current date AND time in Frappe format const nowDateTime = getCurrentDateTimeForFrappe(); try { // Update the document with completion_date await updateWorkOrder(workOrderName, { completion_date: nowDateTime }); // Update local form data (use input format for display) setFormData(prev => ({ ...prev, completion_date: formatDateTimeForInput(nowDateTime) })); toast.info(t('workOrders.detail.completionDateSetTo', { datetime: formatDateTimeForDisplay(nowDateTime) }), { position: "top-right", autoClose: 3000, icon: }); // Refetch to get updated data refetch(); // Refresh audit logs immediately setTimeout(() => { fetchAuditLogs(); }, 500); console.log('Auto-set completion_date to:', nowDateTime); } catch (err) { console.error('Error auto-setting completion_date:', err); } } }; autoSetCompletionDate(); }, [isNewWorkOrder, workOrderName, workOrder?.workflow_state, workOrder?.completion_date]); // ============== FETCH USER ROLES ============== /** * Fetch current user's roles for workflow transition filtering * Uses custom API: asset_lite.api.user_roles.get_user_roles */ // useEffect(() => { // const fetchUserRoles = async () => { // try { // const response = await fetch('/api/method/asset_lite.api.user_roles.get_user_roles', { // method: 'GET', // headers: { 'Content-Type': 'application/json' }, // credentials: 'include', // }); // const data = await response.json(); // if (data.message && Array.isArray(data.message)) { // setUserRoles(data.message); // console.log('User roles fetched:', data.message); // } // } catch (err) { // console.error('Error fetching user roles:', err); // setUserRoles([]); // } finally { // setRolesLoaded(true); // } // }; // fetchUserRoles(); // }, []); useEffect(() => { if (!currentUser) return; // wait until we know who's logged in // Reset immediately so stale roles from previous user don't linger setRolesLoaded(false); setUserRoles([]); const fetchUserRoles = async () => { try { // const response = await fetch('/api/method/asset_lite.api.user_roles.get_user_roles', { // method: 'GET', // headers: { 'Content-Type': 'application/json' }, // credentials: 'include', // }); const response = await fetch(`/api/method/asset_lite.api.user_roles.get_user_roles?_=${Date.now()}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', }, credentials: 'include', }); const data = await response.json(); if (data.message && Array.isArray(data.message)) { setUserRoles(data.message); console.log('User roles fetched for:', currentUser, data.message); } } catch (err) { console.error('Error fetching user roles:', err); setUserRoles([]); } finally { setRolesLoaded(true); } }; fetchUserRoles(); }, [currentUser]); // 👈 re-runs whenever the logged-in user changes /** * Fetch current logged-in user's email for technician comparison */ useEffect(() => { const fetchCurrentUser = async () => { try { // const response = await fetch('/api/method/frappe.auth.get_logged_user', { // method: 'GET', // headers: { 'Content-Type': 'application/json' }, // credentials: 'include', // }); const response = await fetch('/api/method/frappe.auth.get_logged_user', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', }, credentials: 'include', }); const data = await response.json(); if (data.message) { setCurrentUser(data.message); console.log('Current user:', data.message); } } catch (err) { console.error('Error fetching current user:', err); setCurrentUser(''); } }; fetchCurrentUser(); }, []); // ✅ Check user permissions based on roles for delete/cancel useEffect(() => { const checkDeletePermissions = async () => { try { const response = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ roles: DELETE_ALLOWED_ROLES.join(',') }) }); const data = await response.json(); if (data.message) { setCanDelete(data.message.has_role); console.log('Can delete/cancel:', data.message.has_role); } } catch (error) { console.error('Error checking delete permissions:', error); setCanDelete(false); } }; checkDeletePermissions(); }, []); // ============== FETCH AUDIT LOGS ============== /** * Fetch version history for the current work order * Uses Version doctype to get audit trail */ const fetchAuditLogs = useCallback(async () => { if (isNewWorkOrder || !workOrderName) return; setAuditLogsLoading(true); try { const response = await apiService.apiCall( `/api/resource/Version?filters=[["ref_doctype","=","Work_Order"],["docname","=","${encodeURIComponent(workOrderName)}"]]&fields=["name","owner","creation","data"]&order_by=creation desc&limit=50` ); if (response?.data && response.data.length > 0) { const parsedLogs: AuditLogEntry[] = response.data.map((version: any) => { let parsedData = { added: [], changed: [], removed: [], row_changed: [] }; try { parsedData = JSON.parse(version.data || '{}'); } catch (e) { console.error('Error parsing version data:', e); } const changes: VersionChange[] = (parsedData.changed || []).map((change: any[]) => ({ field: change[0] || '', oldValue: change[1], newValue: change[2] })); return { name: version.name, owner: version.owner, creation: version.creation, changes, added: parsedData.added || [], removed: parsedData.removed || [], rowChanged: parsedData.row_changed || [] }; }); setAuditLogs(parsedLogs); console.log('Audit logs fetched:', parsedLogs.length); } else { setAuditLogs([]); } } catch (err) { console.error('Error fetching audit logs:', err); setAuditLogs([]); } finally { setAuditLogsLoading(false); } }, [isNewWorkOrder, workOrderName]); // Initial fetch of audit logs useEffect(() => { fetchAuditLogs(); }, [fetchAuditLogs]); // ============== FEEDBACK FUNCTIONS ============== /** * Fetch current user's full name */ const fetchCurrentUserFullName = useCallback(async () => { try { const response = await fetch('/api/method/frappe.client.get_value', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ doctype: 'User', filters: { name: currentUser }, fieldname: 'full_name' }) }); const data = await response.json(); if (data.message?.full_name) { setCurrentUserFullName(data.message.full_name); } } catch (err) { console.error('Error fetching user full name:', err); } }, [currentUser]); /** * Check if feedback already exists for this work order by the current user */ const fetchExistingFeedback = useCallback(async () => { if (isNewWorkOrder || !workOrderName || !workOrder?.owner) return; setFeedbackLoading(true); try { const filters = JSON.stringify([ ['work_order', '=', workOrderName], ['feedback_by', '=', workOrder?.owner || currentUser] ]); const response = await apiService.apiCall( `/api/resource/Feedback?filters=${encodeURIComponent(filters)}&fields=["name"]&limit=1` ); if (response?.data && response.data.length > 0) { // Fetch full document with child table const fullDoc = await apiService.apiCall( `/api/resource/Feedback/${response.data[0].name}` ); if (fullDoc?.data) { setFeedbackData(fullDoc.data); } } else { setFeedbackData(null); } } catch (err) { console.error('Error fetching feedback:', err); setFeedbackData(null); } finally { setFeedbackLoading(false); } }, [isNewWorkOrder, workOrderName, currentUser]); // Fetch feedback when work order loads and state is Approved/Closed useEffect(() => { const ws = workOrder?.workflow_state || formData.workflow_state || ''; if (['Approved', 'Closed'].includes(ws) && (currentUser || workOrder?.owner)) { fetchExistingFeedback(); if (currentUser) fetchCurrentUserFullName(); } }, [workOrder?.workflow_state, formData.workflow_state, currentUser, workOrder?.owner, fetchExistingFeedback, fetchCurrentUserFullName]); /** * Open Give Feedback modal */ const handleOpenGiveFeedback = () => { setFeedbackRatings([ { parameter: 'Quality of Service', rating: 0, feedback: '' }, { parameter: 'Timeliness', rating: 0, feedback: '' }, { parameter: 'Professionalism', rating: 0, feedback: '' }, { parameter: 'Communication', rating: 0, feedback: '' }, ]); setFeedbackMode('give'); setShowFeedbackModal(true); }; /** * Open See Rating modal */ const handleOpenSeeRating = () => { if (feedbackData?.parameters) { setFeedbackRatings( feedbackData.parameters.map((p: any) => ({ parameter: p.parameter, rating: p.rating || 0, feedback: p.feedback || '', })) ); } setFeedbackMode('view'); setShowFeedbackModal(true); }; /** * Switch to edit mode within the modal */ const handleEditFeedback = () => { setFeedbackMode('edit'); }; /** * Update a rating value for a parameter */ const handleRatingChange = (index: number, rating: number) => { setFeedbackRatings(prev => { const updated = [...prev]; updated[index] = { ...updated[index], rating }; return updated; }); }; /** * Calculate overall satisfaction from ratings */ const calculateOverall = (ratings: {rating: number}[]): number => { const validRatings = ratings.filter(r => r.rating > 0); if (validRatings.length === 0) return 0; const total = validRatings.reduce((sum, r) => sum + r.rating, 0); return parseFloat((total / validRatings.length).toFixed(2)); }; /** * Submit new feedback */ const handleSubmitFeedback = async () => { const hasAnyRating = feedbackRatings.some(r => r.rating > 0); if (!hasAnyRating) { toast.warning(t('workOrders.detail.pleaseProvideAtLeastOneRating'), { position: "top-right", autoClose: 3000, icon: }); return; } setFeedbackSubmitting(true); try { const overall = calculateOverall(feedbackRatings); const payload = { doctype: 'Feedback', work_order: workOrderName, feedback_by: currentUser, overall: overall, parameters: feedbackRatings.map((r, idx) => ({ parameter: r.parameter, rating: r.rating, feedback: r.feedback || null, idx: idx + 1, })), }; const response = await fetch('/api/resource/Feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(payload), }); const result = await response.json(); if (result.data) { setFeedbackData(result.data); setShowFeedbackModal(false); toast.success(t('workOrders.detail.feedbackSubmittedSuccessfully'), { position: "top-right", autoClose: 3000, icon: }); } else { throw new Error(result.exc || 'Failed to submit feedback'); } } catch (err) { console.error('Error submitting feedback:', err); const msg = err instanceof Error ? err.message : 'Unknown error'; toast.error(`${t('workOrders.detail.failedToSubmitFeedback')}: ${msg}`, { position: "top-right", autoClose: 5000, icon: }); } finally { setFeedbackSubmitting(false); } }; /** * Update existing feedback */ const handleUpdateFeedback = async () => { if (!feedbackData?.name) return; setFeedbackSubmitting(true); try { const overall = calculateOverall(feedbackRatings); // Build updated parameters matching existing child rows const updatedParams = feedbackRatings.map((r, idx) => { const existingRow = feedbackData.parameters?.[idx]; return { ...(existingRow?.name ? { name: existingRow.name } : {}), parameter: r.parameter, rating: r.rating, feedback: r.feedback || null, idx: idx + 1, }; }); const response = await fetch(`/api/resource/Feedback/${feedbackData.name}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ overall: overall, parameters: updatedParams, }), }); const result = await response.json(); if (result.data) { setFeedbackData(result.data); setFeedbackMode('view'); toast.success(t('workOrders.detail.feedbackUpdatedSuccessfully'), { position: "top-right", autoClose: 3000, icon: }); } else { throw new Error(result.exc || 'Failed to update feedback'); } } catch (err) { console.error('Error updating feedback:', err); const msg = err instanceof Error ? err.message : 'Unknown error'; toast.error(`${t('workOrders.detail.failedToUpdateFeedback')}: ${msg}`, { position: "top-right", autoClose: 5000, icon: }); } finally { setFeedbackSubmitting(false); } }; // ============== END FEEDBACK FUNCTIONS ============== /** * Map work order type to expertise for team filtering * Some WO types share the same maintenance team expertise */ const getExpertiseForFiltering = (workOrderType: string): string => { const EXPERTISE_MAPPING: Record = { 'Medical Gas – الغازات الطبية': 'Mechanical Maintenance-الصيانة الميكانيكية', }; return EXPERTISE_MAPPING[workOrderType] || workOrderType; }; // ============== FILTERING FUNCTIONS ============== /** * Filter Team Leaders (Managers) based on Work Order Type (Expertise) * Fetches Asset Maintenance Team records matching the work_order_type */ const filterManagersBasedOnExpertise = useCallback(async (workOrderType: string) => { if (!workOrderType) { setFilteredManagers([]); return; } const expertise = getExpertiseForFiltering(workOrderType); try { const response = await apiService.apiCall( `/api/resource/Asset Maintenance Team?filters=[["custom_expertise","=","${encodeURIComponent(expertise)}"]]&fields=["maintenance_manager"]&limit=100` ); if (response?.data && response.data.length > 0) { // Extract unique managers const managers = response.data .map((r: any) => r.maintenance_manager) .filter((m: string) => m); // Remove null/empty values const uniqueManagers = [...new Set(managers)] as string[]; console.log('Filtered Managers based on expertise:', uniqueManagers); setFilteredManagers(uniqueManagers); } else { setFilteredManagers([]); } } catch (err) { console.error('Error fetching managers based on expertise:', err); setFilteredManagers([]); } }, []); /** * Filter Technicians based on Work Order Type (Expertise) only * Fetches all Asset Maintenance Team records with same expertise and collects all team members */ const filterTechniciansBasedOnExpertise = useCallback(async (workOrderType: string) => { if (!workOrderType) { setFilteredTechnicians([]); return; } const expertise = getExpertiseForFiltering(workOrderType); try { console.log('Filtering technicians for work order type:', workOrderType, '→ expertise:', expertise); // Get all Asset Maintenance Team records with same expertise const response = await apiService.apiCall( `/api/resource/Asset Maintenance Team?filters=[["custom_expertise","=","${encodeURIComponent(expertise)}"]]&fields=["name"]&limit=100` ); if (response?.data && response.data.length > 0) { const memberSet = new Set(); // Fetch each team document and collect members const promises = response.data.map(async (record: any) => { try { const teamResponse = await apiService.apiCall( `/api/resource/Asset Maintenance Team/${encodeURIComponent(record.name)}` ); if (teamResponse?.data) { const teamMembers = teamResponse.data.maintenance_team_members || []; teamMembers.forEach((row: any) => { if (row.team_member) { memberSet.add(row.team_member); } }); } } catch (err) { console.error('Error fetching team:', record.name, err); } }); // Wait for all team documents to be fetched await Promise.all(promises); const memberList = Array.from(memberSet); console.log('Filtered Technicians:', memberList); setFilteredTechnicians(memberList); } else { setFilteredTechnicians([]); } } catch (err) { console.error('Error fetching technicians:', err); setFilteredTechnicians([]); } }, []); /** * Filter Engineers based on Work Order Type (Expertise) and maintenance_role = 'Contractor Engineer' * Fetches Asset Maintenance Team records matching the work_order_type and collects team members * where maintenance_role equals 'Contractor Engineer' */ const filterEngineersBasedOnExpertise = useCallback(async (workOrderType: string) => { if (!workOrderType) { setFilteredEngineers([]); return; } const expertise = getExpertiseForFiltering(workOrderType); try { console.log('Filtering engineers for work order type:', workOrderType, '→ expertise:', expertise); // Get all Asset Maintenance Team records with same expertise const response = await apiService.apiCall( `/api/resource/Asset Maintenance Team?filters=[["custom_expertise","=","${encodeURIComponent(expertise)}"]]&fields=["name"]&limit=100` ); if (response?.data && response.data.length > 0) { const engineerSet = new Set(); // Fetch each team document and collect members with role 'Contractor Engineer' const promises = response.data.map(async (record: any) => { try { const teamResponse = await apiService.apiCall( `/api/resource/Asset Maintenance Team/${encodeURIComponent(record.name)}` ); if (teamResponse?.data) { const teamMembers = teamResponse.data.maintenance_team_members || []; teamMembers.forEach((row: any) => { // Only add team members with maintenance_role = 'Contractor Engineer' if (row.team_member && row.maintenance_role === 'Contractor Engineer') { engineerSet.add(row.team_member); } }); } } catch (err) { console.error('Error fetching team for engineers:', record.name, err); } }); // Wait for all team documents to be fetched await Promise.all(promises); const engineerList = Array.from(engineerSet); console.log('Filtered Engineers (Contractor Engineer role):', engineerList); setFilteredEngineers(engineerList); } else { setFilteredEngineers([]); } } catch (err) { console.error('Error fetching engineers:', err); setFilteredEngineers([]); } }, []); // ============== CASCADING LOCATION FILTERS ============== /** * Fetch departments based on selected building from Infrastructure Location */ const fetchDepartmentsForBuilding = useCallback(async (building: string) => { if (!building) { setFilteredDepartmentsForBuilding([]); return; } setLoadingLocationData(true); try { const filters = JSON.stringify([['building', '=', building]]); const response = await apiService.apiCall( `/api/resource/Infrastructure Location?filters=${encodeURIComponent(filters)}&fields=["department"]&limit=9999` ); if (response?.data && response.data.length > 0) { // Extract unique departments const departments = response.data .map((r: any) => r.department) .filter((d: string) => d); // Remove null/empty values const uniqueDepartments = [...new Set(departments)] as string[]; console.log('Filtered Departments for building:', building, uniqueDepartments); setFilteredDepartmentsForBuilding(uniqueDepartments); } else { setFilteredDepartmentsForBuilding([]); } } catch (err) { console.error('Error fetching departments for building:', err); setFilteredDepartmentsForBuilding([]); } finally { setLoadingLocationData(false); } }, []); /** * Fetch room count based on available filters (building and/or department) * Uses custom API to get count from Infrastructure Location mapping */ const fetchRoomCountForFilter = useCallback(async (building?: string, department?: string) => { // If neither building nor department is set, clear the count if (!building && !department) { setFilteredRoomNosForDepartment([]); return; } setLoadingLocationData(true); try { const params = new URLSearchParams(); if (building) params.append('building', building); if (department) params.append('department', department); const response = await fetch(`/api/method/asset_lite.api.room_filter.get_room_count?${params.toString()}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, credentials: 'include', }); const data = await response.json(); const count = data.message || 0; // Store count as array length for existing helper text logic // We just need the length, not actual room names setFilteredRoomNosForDepartment(new Array(count).fill('')); console.log('Room count:', { building, department, count }); } catch (err) { console.error('Error fetching room count:', err); setFilteredRoomNosForDepartment([]); } finally { setLoadingLocationData(false); } }, []); /** * Auto-fetch and set location based on available filters * Works with any combination: building + room, department + room, or all three */ const fetchAndSetLocation = useCallback(async (building?: string, department?: string, roomNo?: string) => { // Room No is required at minimum if (!roomNo) { return; } // Need at least one of building or department along with room_no if (!building && !department) { return; } setLoadingLocationData(true); try { // Build filters dynamically based on available data const filterArray: [string, string, string][] = [ ['room_no', '=', roomNo] ]; if (building) { filterArray.push(['building', '=', building]); } if (department) { filterArray.push(['department', '=', department]); } const filters = JSON.stringify(filterArray); const response = await apiService.apiCall( `/api/resource/Infrastructure Location?filters=${encodeURIComponent(filters)}&fields=["location"]&limit=1` ); if (response?.data && response.data.length > 0 && response.data[0].location) { const location = response.data[0].location; console.log('Auto-setting location:', location, { building, department, roomNo }); setFormData(prev => { // Only update and show toast if location is actually changing if (prev.custom_location === location) { return prev; // No change needed } toast.info(t('workOrders.detail.locationAutoSetTo', { location }), { position: "top-right", autoClose: 2000, icon: }); return { ...prev, custom_location: location }; }); } } catch (err) { console.error('Error fetching location:', err); } finally { setLoadingLocationData(false); } }, []); // ============== END CASCADING LOCATION FILTERS ============== /** * Fetch all available technicians for multi-select * Uses custom API to get users with Technician role */ const fetchAllTechnicians = useCallback(async () => { setLoadingTechnicians(true); try { const response = await fetch('/api/method/asset_lite.api.user_roles.get_users_with_role', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ role: 'Technician' }) }); const data = await response.json(); if (data.message && Array.isArray(data.message)) { const technicians = data.message.map((user: any) => ({ name: user.name, full_name: user.full_name || user.name })); setAvailableTechnicians(technicians); console.log('Fetched technicians:', technicians.length); } else { setAvailableTechnicians([]); console.log('No technicians found'); } } catch (err) { console.error('Error fetching technicians:', err); setAvailableTechnicians([]); } finally { setLoadingTechnicians(false); } }, []); /** * Open the multi-technician selection modal */ const handleOpenTechnicianModal = () => { // Parse existing selected technicians from custom_add_technicians field const existingTechnicians = formData.custom_add_technicians ? formData.custom_add_technicians.split(',').map(t => t.trim()).filter(t => t) : []; setSelectedAdditionalTechnicians(existingTechnicians); setTechnicianSearchQuery(''); setShowTechnicianModal(true); fetchAllTechnicians(); }; /** * Handle technician selection toggle */ const handleTechnicianToggle = (technicianName: string) => { setSelectedAdditionalTechnicians(prev => { if (prev.includes(technicianName)) { return prev.filter(t => t !== technicianName); } else { return [...prev, technicianName]; } }); }; /** * Show confirmation dialog before saving technicians */ const handleSaveTechnicians = () => { if (selectedAdditionalTechnicians.length === 0) { toast.warning(t('workOrders.detail.noTechniciansSelected'), { position: "top-right", autoClose: 3000, icon: }); return; } // Store pending selection and show confirmation setPendingTechnicians(selectedAdditionalTechnicians); setShowTechnicianConfirm(true); }; /** * Confirm and save selected technicians */ const handleConfirmTechnicians = () => { const technicianString = pendingTechnicians.join(','); setFormData(prev => ({ ...prev, custom_add_technicians: technicianString })); setShowTechnicianConfirm(false); setShowTechnicianModal(false); setPendingTechnicians([]); toast.success(t('workOrders.detail.techniciansAssignedSuccessfully', { count: pendingTechnicians.length }), { position: "top-right", autoClose: 3000, icon: }); }; /** * Cancel technician confirmation */ const handleCancelTechnicianConfirm = () => { setShowTechnicianConfirm(false); setPendingTechnicians([]); // Keep the modal open so user can modify selection }; /** * Remove a technician from the selection */ const handleRemoveTechnician = (technicianName: string) => { const currentTechnicians = formData.custom_add_technicians ? formData.custom_add_technicians.split(',').map(t => t.trim()).filter(t => t) : []; const updatedTechnicians = currentTechnicians.filter(t => t !== technicianName); setFormData(prev => ({ ...prev, custom_add_technicians: updatedTechnicians.join(',') })); toast.info(t('workOrders.detail.removedTechnician', { name: technicianName }), { position: "top-right", autoClose: 2000 }); }; /** * Filter technicians based on search query */ const filteredAvailableTechnicians = useMemo(() => { if (!technicianSearchQuery) return availableTechnicians; const query = technicianSearchQuery.toLowerCase(); return availableTechnicians.filter(t => t.name.toLowerCase().includes(query) || (t.full_name && t.full_name.toLowerCase().includes(query)) ); }, [availableTechnicians, technicianSearchQuery]); // ============== END MULTI-TECHNICIAN SELECTION ============== // Effect to filter managers when work_order_type changes useEffect(() => { if (formData.work_order_type) { filterManagersBasedOnExpertise(formData.work_order_type); } else { setFilteredManagers([]); } }, [formData.work_order_type, filterManagersBasedOnExpertise]); // Effect to filter technicians when work_order_type changes (not dependent on supervisor anymore) useEffect(() => { if (formData.work_order_type) { filterTechniciansBasedOnExpertise(formData.work_order_type); } else { setFilteredTechnicians([]); } }, [formData.work_order_type, filterTechniciansBasedOnExpertise]); // Effect to filter engineers when work_order_type changes useEffect(() => { if (formData.work_order_type) { filterEngineersBasedOnExpertise(formData.work_order_type); } else { setFilteredEngineers([]); } }, [formData.work_order_type, filterEngineersBasedOnExpertise]); // ============== CASCADING LOCATION FILTER EFFECTS ============== // Effect to fetch departments when building changes useEffect(() => { if (formData.custom_building) { fetchDepartmentsForBuilding(formData.custom_building); } else { setFilteredDepartmentsForBuilding([]); } }, [formData.custom_building, fetchDepartmentsForBuilding]); // Effect to fetch room count when building OR department changes useEffect(() => { const building = formData.custom_building || undefined; const department = formData.department || undefined; // Fetch count if at least one filter is available if (building || department) { fetchRoomCountForFilter(building, department); } else { setFilteredRoomNosForDepartment([]); } }, [formData.custom_building, formData.department, fetchRoomCountForFilter]); // Effect to auto-set location when room_no is selected (with any available filters) useEffect(() => { const building = formData.custom_building || undefined; const department = formData.department || undefined; const roomNo = formData.custom_room_no || undefined; // Need room_no AND at least one of building/department if (roomNo && (building || department)) { fetchAndSetLocation(building, department, roomNo); } }, [formData.custom_building, formData.department, formData.custom_room_no, fetchAndSetLocation]); // ============== END CASCADING LOCATION FILTER EFFECTS ============== // Calculate deadline date based on failure_date, priority, and need_procurement // Updated: Normal (5d), Medium (3d), Urgent (1d) const calculateDeadlineDate = useCallback(( failureDate: string, priority: string, needProcurement: number ): string => { if (!failureDate) return ''; let daysToAdd = 0; const isProcurementNeeded = needProcurement === 1; if (priority === 'Normal') { daysToAdd = isProcurementNeeded ? 30 : 5; } else if (priority === 'Medium') { daysToAdd = isProcurementNeeded ? 30 : 3; } else if (priority === 'Urgent') { daysToAdd = isProcurementNeeded ? 30 : 1; } return addDays(failureDate, daysToAdd); }, []); // Update deadline date when failure_date, priority, or need_procurement changes useEffect(() => { // Only auto-calculate when editing if (!isEditing) return; const newDeadlineDate = calculateDeadlineDate( formData.failure_date, formData.custom_priority_ || 'Normal', formData.need_procurement || 0 ); if (newDeadlineDate && newDeadlineDate !== formData.custom_deadline_date) { setFormData(prev => ({ ...prev, custom_deadline_date: newDeadlineDate })); } }, [formData.failure_date, formData.custom_priority_, formData.need_procurement, isEditing, calculateDeadlineDate]); // Clear asset-related fields when asset type changes to Non Biomedical useEffect(() => { if (isNonBiomedical && isEditing) { setFormData(prev => ({ ...prev, // Clear fields that should be hidden for Non Biomedical asset: '', asset_name: '', serial_number: '', manufacturer: '', supplier: '', model: '', })); } }, [isNonBiomedical, isEditing]); // Frappe dynamic field behavior - evaluates depends_on, mandatory_depends_on, read_only_depends_on const { shouldShowField: frappeShowField, isMandatory: frappeMandatory, isReadOnly: frappeReadOnly, loading: fieldConfigLoading } = useFrappeFieldBehavior('Work_Order', formData as Record); // Helper function to check if field should be visible based on Frappe's depends_on const shouldShowField = useCallback((fieldname: string): boolean => { return frappeShowField(fieldname); }, [frappeShowField]); // Helper function to check if field is mandatory based on Frappe's mandatory_depends_on const isFieldMandatory = useCallback((fieldname: string): boolean => { return frappeMandatory(fieldname); }, [frappeMandatory]); // Helper function to check if field is read-only based on Frappe's read_only_depends_on const isFieldReadOnlyFromFrappe = useCallback((fieldname: string): boolean => { return frappeReadOnly(fieldname); }, [frappeReadOnly]); // ============== SEQUENTIAL LOCATION FIELD VISIBILITY ============== /** * Helper functions to determine if location-related fields should be visible * Logic: Show field if previous field has value OR current field already has value */ const shouldShowDepartmentField = useCallback((): boolean => { // Show if Building has value OR Department already has value return !!(formData.custom_building || formData.department); }, [formData.custom_building, formData.department]); const shouldShowRoomNoField = useCallback((): boolean => { // Show if Department has value OR Room No already has value return !!(formData.department || formData.custom_room_no); }, [formData.department, formData.custom_room_no]); const shouldShowLocationField = useCallback((): boolean => { // Show if Room No has value OR Location already has value return !!(formData.custom_room_no || formData.custom_location); }, [formData.custom_room_no, formData.custom_location]); // ============== END SEQUENTIAL LOCATION FIELD VISIBILITY ============== // Combined helper to check if field should be disabled (not editing OR Frappe read_only_depends_on OR form is read-only) const isFieldDisabled = useCallback((fieldname: string): boolean => { if (isFormReadOnly) return true; if (isDeletePending) return true; // ← ADD THIS if (!isEditing) return true; return isFieldReadOnlyFromFrappe(fieldname); }, [isEditing, isFieldReadOnlyFromFrappe, isFormReadOnly, isDeletePending]); // Filter rooms based on search query (local filtering) // Show first 30 when no search, filter when searching const searchFilteredRooms = useMemo(() => { if (!roomSearchQuery) { // No search - show first 30 rooms by default return filteredRoomNosForDepartment.slice(0, 30); } // Search - filter and show max 50 results const query = roomSearchQuery.toLowerCase(); return filteredRoomNosForDepartment .filter(room => room.toLowerCase().includes(query)) .slice(0, 50); }, [filteredRoomNosForDepartment, roomSearchQuery]); // Document data for workflow condition evaluation const workOrderDocData = useMemo(() => { if (isNewWorkOrder) return undefined; return { // Fields used in workflow conditions asset_type: workOrder?.asset_type || formData.asset_type || '', site_name: workOrder?.site_name || formData.site_name || '', need_procurement: workOrder?.need_procurement || formData.need_procurement || 0, custom_assign_to_contractor: workOrder?.custom_assign_to_contractor || formData.custom_assign_to_contractor || '', assigned_technician: workOrder?.assigned_technician || formData.assigned_technician || '', custom_assigned_engineer: workOrder?.custom_assigned_engineer || formData.custom_assigned_engineer || '', docstatus: workOrder?.docstatus || formData.docstatus || 0, // Include other fields that might be used in conditions company: workOrder?.company || formData.company || '', department: workOrder?.department || formData.department || '', repair_status: workOrder?.repair_status || formData.repair_status || '', first_responded_on: workOrder?.first_responded_on || formData.first_responded_on || '', }; }, [ isNewWorkOrder, workOrder?.asset_type, workOrder?.site_name, workOrder?.need_procurement, workOrder?.custom_assign_to_contractor, workOrder?.docstatus, workOrder?.company, workOrder?.department, workOrder?.repair_status, workOrder?.first_responded_on, formData.first_responded_on, formData.asset_type, formData.site_name, formData.need_procurement, formData.custom_assign_to_contractor, formData.docstatus, formData.company, formData.department, formData.repair_status ]); // Workflow hook - now with docData for condition evaluation const { transitions: rawTransitions, loading: workflowLoading, actionLoading, error: workflowError, canEdit: workflowCanEdit, isSystemManager, applyAction, getStateStyle, getButtonStyle, getIcon, } = useWorkflow({ doctype: 'Work_Order', docname: isNewWorkOrder ? null : workOrderName || null, workflowState: workOrder?.workflow_state, enabled: !isNewWorkOrder, docData: workOrderDocData, // Pass document data for condition evaluation }); // ============== WORKFLOW TRANSITION FILTERING ============== /** * Filter workflow transitions based on role priority and technician assignment * Priority: System Manager > Work Control > End user * * Special rule: "Send For Approval" in "Repair InProgress" state * should only be visible to the assigned_technician * * Special rule: "Apply" action for Work Control role * should only be visible when assigned_supervisor AND assigned_technician are set */ const transitions = useMemo(() => { if (!rawTransitions || rawTransitions.length === 0) { setApplyHiddenReason(null); return []; } const hasWorkControlRole = userRoles.includes('Work Control'); const hasSystemManagerRole = userRoles.includes('System Manager') || isSystemManager; // Get current workflow state (reactive to form changes) const currentWorkflowState = workOrder?.workflow_state || formData.workflow_state || 'Draft'; // Get assigned technician - prioritize form data for immediate reactivity const assignedTechnician = formData.assigned_technician || workOrder?.assigned_technician || ''; // Get assigned supervisor - prioritize form data for immediate reactivity const assignedSupervisor = formData.custom_assigned_supervisor || workOrder?.custom_assigned_supervisor || ''; // Get assigned engineer const assignedEngineer = formData.custom_assigned_engineer || workOrder?.custom_assigned_engineer || ''; // Check if work_order_type has been SAVED with a different value (for "Back To Controller" logic) // Compare the SAVED value (from workOrder) with initial value, NOT the form value const savedWorkOrderType = workOrder?.work_order_type || ''; const isWorkOrderTypeSavedDifferent = initialWorkOrderType !== '' && savedWorkOrderType !== '' && savedWorkOrderType !== initialWorkOrderType; // ============== SPECIAL HANDLING FOR "Sent to Engineer" STATE ============== if (currentWorkflowState === 'Sent to Engineer') { // If work_order_type has been SAVED with different value, show only "Back To Controller" button if (isWorkOrderTypeSavedDifferent) { setTimeout(() => setApplyHiddenReason('WO Type has been changed. Use "Back To Controller" to send for reassignment, or revert the change.'), 0); // Filter to show only "Back To Controller" action const backToControllerTransition = rawTransitions.find( (t: WorkflowTransition) => t.action === 'Back To Controller' && t.state === 'Sent to Engineer' ); return backToControllerTransition ? [backToControllerTransition] : []; } // Get Technical Report value const technicalReport = formData.actions_performed || workOrder?.actions_performed || ''; const hasTechnicalReport = technicalReport.trim().length > 0; const hasTechnician = !!assignedTechnician; // Get all transitions for this state const allSentToEngineerTransitions = rawTransitions.filter( (t: WorkflowTransition) => t.state === 'Sent to Engineer' ); // Find specific transitions const sendToSupervisorTransition = allSentToEngineerTransitions.find( (t: WorkflowTransition) => t.action === 'Send to Supervisor' ); const backToControllerTransition = allSentToEngineerTransitions.find( (t: WorkflowTransition) => t.action === 'Back To Controller' ); const otherTransitions = allSentToEngineerTransitions.filter( (t: WorkflowTransition) => t.action !== 'Send to Supervisor' && t.action !== 'Back To Controller' ); const availableTransitions: WorkflowTransition[] = []; // Case 1: Both technician AND technical report filled → Show ALL buttons if (hasTechnician && hasTechnicalReport) { setTimeout(() => setApplyHiddenReason(null), 0); // Add all transitions including "Send to Supervisor" availableTransitions.push(...otherTransitions); if (sendToSupervisorTransition) { availableTransitions.push(sendToSupervisorTransition); } if (backToControllerTransition) { availableTransitions.push(backToControllerTransition); } } // Case 2: Only technician assigned (no technical report) else if (hasTechnician && !hasTechnicalReport) { setTimeout(() => setApplyHiddenReason('Technician assigned. Fill "Technical Report" to also enable "Send to Supervisor" option.'), 0); // Show technician workflow buttons, exclude "Send to Supervisor" availableTransitions.push(...otherTransitions); if (backToControllerTransition) { availableTransitions.push(backToControllerTransition); } } // Case 3: Only technical report filled (no technician) else if (!hasTechnician && hasTechnicalReport) { setTimeout(() => setApplyHiddenReason('Technical Report filled. You can "Send to Supervisor" directly, OR assign a Technician for more workflow options.'), 0); // Show "Send to Supervisor" button if (sendToSupervisorTransition) { availableTransitions.push(sendToSupervisorTransition); } if (backToControllerTransition) { availableTransitions.push(backToControllerTransition); } } // Case 4: Neither filled - show only Back To Controller else { setTimeout(() => setApplyHiddenReason(null), 0); // We'll show the "Choose Your Path" message instead if (backToControllerTransition) { availableTransitions.push(backToControllerTransition); } } return availableTransitions; } // ============== SPECIAL HANDLING FOR "Repair InProgress" STATE ============== if (currentWorkflowState === 'Repair InProgress') { // If work_order_type has been SAVED with different value, show only "Back To Controller" button if (isWorkOrderTypeSavedDifferent) { setTimeout(() => setApplyHiddenReason('WO Type has been changed. Use "Back To Controller" to send for reassignment, or revert the change.'), 0); // Filter to show only "Back To Controller" action const backToControllerTransition = rawTransitions.find( (t: WorkflowTransition) => t.action === 'Back To Controller' && t.state === 'Repair InProgress' ); return backToControllerTransition ? [backToControllerTransition] : []; } // ✅ ADD THIS BLOCK - Check Technical Report for "Send For Approval" const technicalReport = formData.actions_performed || workOrder?.actions_performed || ''; const hasTechnicalReport = technicalReport.trim().length > 0; // Get all transitions for this state const allRepairInProgressTransitions = rawTransitions.filter( (t: WorkflowTransition) => t.state === 'Repair InProgress' ); // If Technical Report is empty, filter out "Send For Approval" and show message if (!hasTechnicalReport) { setTimeout(() => setApplyHiddenReason('Please fill the "Technical Report" field to enable "Send For Approval" action.'), 0); // Return all transitions EXCEPT "Send For Approval" return allRepairInProgressTransitions.filter( (t: WorkflowTransition) => t.action !== 'Send For Approval' ); } else { // Technical Report is filled, show all transitions setTimeout(() => setApplyHiddenReason(null), 0); return allRepairInProgressTransitions; } } // Group transitions by action const transitionsByAction: Record = {}; rawTransitions.forEach((t: WorkflowTransition) => { if (!transitionsByAction[t.action]) { transitionsByAction[t.action] = []; } transitionsByAction[t.action].push(t); }); const filteredTransitions: WorkflowTransition[] = []; let applyHiddenReasonTemp: string | null = null; Object.entries(transitionsByAction).forEach(([action, actionTransitions]) => { if (actionTransitions.length === 1) { // Only one transition for this action const transition = actionTransitions[0]; // Check if this is "Apply" for Work Control and user has that role if ( action === 'Apply' && transition.allowed === 'Work Control' && (hasWorkControlRole || hasSystemManagerRole) ) { const missingFields: string[] = []; if (!assignedSupervisor) { missingFields.push('Assigned Supervisor'); } if (!assignedEngineer) { missingFields.push('Assigned Engineer'); } if (missingFields.length > 0) { applyHiddenReasonTemp = `Please assign ${missingFields.join(' and ')} before applying`; console.log(`Apply hidden for Work Control: ${applyHiddenReasonTemp}`); // Don't add this transition return; } } filteredTransitions.push(transition); } else { // Multiple transitions for same action (e.g., Apply for End user and Work Control) const workControlTransition = actionTransitions.find( (t: WorkflowTransition) => t.allowed === 'Work Control' ); const endUserTransition = actionTransitions.find( (t: WorkflowTransition) => t.allowed === 'End user' ); if (hasWorkControlRole || hasSystemManagerRole) { // User has Work Control or System Manager role // Prioritize Work Control transition over End user if (workControlTransition) { // Check if Apply action has required fields if (action === 'Apply') { const missingFields: string[] = []; if (!assignedSupervisor) { missingFields.push('Assigned Supervisor'); } if (!assignedEngineer) { missingFields.push('Assigned Engineer'); } if (missingFields.length > 0) { applyHiddenReasonTemp = `Please assign ${missingFields.join(' and ')} before applying`; console.log(`Apply hidden for Work Control: ${applyHiddenReasonTemp}`); // Don't add Apply transition for Work Control return; } } filteredTransitions.push(workControlTransition); } else { // No Work Control transition, include all others actionTransitions.forEach(t => filteredTransitions.push(t)); } } else { // User doesn't have Work Control role, show what they're allowed to see // End user sees their own Apply button (no validation needed) actionTransitions.forEach(t => filteredTransitions.push(t)); } } }); // Update the hidden reason state (defer to avoid state update during render) setTimeout(() => setApplyHiddenReason(applyHiddenReasonTemp), 0); // ============== SPECIAL FILTERING FOR "Send For Approval" ============== return filteredTransitions.filter(transition => { if ( transition.action === 'Send For Approval' && currentWorkflowState === 'Repair InProgress' ) { if (hasSystemManagerRole) { return true; } if (!currentUser) { return false; } const isAssignedTechnician = currentUser === assignedTechnician; if (isAssignedTechnician) { return true; } const additionalTechnicians = formData.custom_add_technicians || workOrder?.custom_add_technicians || ''; const additionalTechniciansList = additionalTechnicians .split(',') .map(t => t.trim()) .filter(t => t); const isAdditionalTechnician = additionalTechniciansList.includes(currentUser); if (isAdditionalTechnician) { return true; } return false; } return true; }); }, [ rawTransitions, userRoles, isSystemManager, currentUser, formData.assigned_technician, formData.custom_assigned_supervisor, formData.custom_assigned_engineer, formData.workflow_state, formData.custom_add_technicians, formData.actions_performed, workOrder?.assigned_technician, workOrder?.custom_assigned_supervisor, workOrder?.custom_assigned_engineer, workOrder?.workflow_state, workOrder?.custom_add_technicians, workOrder?.work_order_type, workOrder?.actions_performed, initialWorkOrderType ]); // ============== END WORKFLOW TRANSITION FILTERING ============== // Check if user has Work Control role for UI display const hasWorkControlRoleForUI = useMemo(() => { return userRoles.includes('Work Control') || isSystemManager; }, [userRoles, isSystemManager]); // Default warehouse for new stock items - Updated to new value const DEFAULT_WAREHOUSE = 'Stores - KFSH-D'; // Stock warnings state const [stockWarnings, setStockWarnings] = useState>({}); // Function to fetch available stock from Bin doctype const fetchAvailableStock = async (itemCode: string, warehouse: string): Promise => { if (!itemCode || !warehouse) return 0; try { const response = await apiService.apiCall( `/api/resource/Bin?filters=[["item_code","=","${itemCode}"],["warehouse","=","${warehouse}"]]&fields=["actual_qty"]&limit=1` ); if (response?.data && response.data.length > 0) { return response.data[0].actual_qty || 0; } return 0; } catch (err) { console.error('Error fetching available stock:', err); return 0; } }; const fetchItemValuationRate = async (itemCode: string): Promise => { if (!itemCode) return 0; try { const response = await apiService.apiCall( `/api/resource/Item/${itemCode}?fields=["valuation_rate"]` ); return response?.data?.valuation_rate || 0; } catch (err) { console.error('Error fetching valuation rate:', err); return 0; } }; const fetchItemDefaultWarehouse = async (itemCode: string): Promise => { if (!itemCode) return DEFAULT_WAREHOUSE; try { const response = await apiService.apiCall( `/api/resource/Item/${itemCode}?fields=["item_defaults"]` ); const itemDefaults = response?.data?.item_defaults || []; if (itemDefaults.length > 0 && itemDefaults[0].default_warehouse) { return itemDefaults[0].default_warehouse; } return DEFAULT_WAREHOUSE; } catch (err) { console.error('Error fetching item default warehouse:', err); return DEFAULT_WAREHOUSE; } }; // Handle item code change - fetch valuation rate and available stock const handleItemCodeChange = async (index: number, itemCode: string) => { const updatedItems = [...(formData.stock_items || [])]; // Check if item already exists in other rows (duplicate validation) if (itemCode) { const existingIndex = updatedItems.findIndex( (item, idx) => idx !== index && item.item_code === itemCode ); if (existingIndex !== -1) { // Item already exists in another row - show warning and clear selection toast.warning(t('workOrders.detail.itemAlreadyAdded', { itemCode, row: existingIndex + 1 }), { position: "top-right", autoClose: 4000, icon: , toastId: `duplicate-item-${index}` // Prevent duplicate toasts }); // Clear the item_code for this row (reset to empty) updatedItems[index] = { ...updatedItems[index], item_code: '', item_name: '', valuation_rate: 0, custom_available_stock: 0, total_value: 0 }; setFormData({ ...formData, stock_items: updatedItems }); return; // Exit early - don't proceed with fetching data } } // No duplicate - proceed with normal flow updatedItems[index] = { ...updatedItems[index], item_code: itemCode }; if (itemCode) { // Fetch valuation rate and default warehouse in parallel const [valuationRate, defaultWarehouse] = await Promise.all([ fetchItemValuationRate(itemCode), fetchItemDefaultWarehouse(itemCode) ]); updatedItems[index].valuation_rate = valuationRate; updatedItems[index].total_value = valuationRate * (updatedItems[index].consumed_quantity || 1); // Set warehouse from item defaults (overrides current warehouse) updatedItems[index].warehouse = defaultWarehouse; // Fetch available stock with the resolved warehouse const availableStock = await fetchAvailableStock(itemCode, defaultWarehouse); updatedItems[index].custom_available_stock = availableStock; validateStock(index, updatedItems[index].consumed_quantity, availableStock, itemCode, defaultWarehouse); } setFormData({ ...formData, stock_items: updatedItems }); }; // ============== CASCADING LOCATION HANDLERS ============== /** * Handle Building change - clears dependent fields (department, room_no, location) */ const handleBuildingChange = (val: string) => { setFormData(prev => ({ ...prev, custom_building: val, // Clear dependent fields when building changes department: '', custom_room_no: '', custom_location: '' })); }; /** * Handle Department change for location cascade - clears room_no and location */ const handleDepartmentChangeForLocation = (val: string) => { setFormData(prev => ({ ...prev, department: val, // Clear dependent fields when department changes custom_room_no: '', custom_location: '' })); }; /** * Handle Room No change - triggers location auto-set */ const handleRoomNoChangeWithLocation = (val: string) => { setFormData(prev => ({ ...prev, custom_room_no: val, // Clear location - it will be auto-set by the useEffect custom_location: '' })); }; // ============== END CASCADING LOCATION HANDLERS ============== // Handle warehouse change - fetch available stock const handleWarehouseChange = async (index: number, warehouse: string) => { const updatedItems = [...(formData.stock_items || [])]; updatedItems[index] = { ...updatedItems[index], warehouse }; if (warehouse && updatedItems[index].item_code) { const availableStock = await fetchAvailableStock(updatedItems[index].item_code, warehouse); updatedItems[index].custom_available_stock = availableStock; // Check stock and set warning validateStock(index, updatedItems[index].consumed_quantity, availableStock, updatedItems[index].item_code, warehouse); } setFormData({ ...formData, stock_items: updatedItems }); }; // Validate stock quantity const validateStock = (index: number, consumedQty: number, availableStock: number, itemCode: string, warehouse: string) => { if (consumedQty > availableStock) { setStockWarnings(prev => ({ ...prev, [index]: `Insufficient stock for ${itemCode} in ${warehouse}. Available: ${availableStock}, Required: ${consumedQty}` })); toast.warning(t('workOrders.detail.insufficientStock', { itemCode, available: availableStock, required: consumedQty }), { position: "top-right", autoClose: 5000, icon: , toastId: `stock-warning-${index}` // Prevent duplicate toasts for same item }); } else { setStockWarnings(prev => { const newWarnings = { ...prev }; delete newWarnings[index]; return newWarnings; }); } }; // Handle consumed quantity change const handleConsumedQtyChange = (index: number, qty: number) => { const updatedItems = [...(formData.stock_items || [])]; const rate = updatedItems[index].valuation_rate || 0; const availableStock = updatedItems[index].custom_available_stock || 0; updatedItems[index] = { ...updatedItems[index], consumed_quantity: qty, total_value: rate * qty }; // Validate stock if (updatedItems[index].item_code && updatedItems[index].warehouse) { validateStock(index, qty, availableStock, updatedItems[index].item_code, updatedItems[index].warehouse); } setFormData({ ...formData, stock_items: updatedItems }); }; // Department filters based on company const [departmentFilters, setDepartmentFilters] = useState>({}); // Update department filters when company changes useEffect(() => { const filters: Record = {}; if (formData.company) { filters['company'] = formData.company; } setDepartmentFilters(filters); }, [formData.company]); // Function to fetch asset details by Asset ID const fetchAssetDetails = async (assetId: string) => { if (!assetId) return null; try { setIsLoadingAsset(true); const response = await apiService.apiCall(`/api/resource/Asset/${assetId}`); return response?.data || null; } catch (err) { console.error('Error fetching asset details:', err); return null; } finally { setIsLoadingAsset(false); } }; // Function to search asset by Serial Number const fetchAssetBySerialNumber = async (serialNumber: string) => { if (!serialNumber) return null; try { setIsLoadingAsset(true); const response = await apiService.apiCall( `/api/resource/Asset?filters=[["custom_serial_number","=","${serialNumber}"]]&fields=["name","asset_name","company","department","custom_serial_number","custom_asset_type","custom_manufacturer","supplier","custom_site_contractor","custom_subcontractor","custom_model","custom_service_agreement","custom_service_coverage","custom_start_date","custom_end_date","custom_total_amount","custom_site","location","custom_building","custom_room_number"]&limit=1` ); if (response?.data && response.data.length > 0) { return response.data[0]; } return null; } catch (err) { console.error('Error fetching asset by serial number:', err); return null; } finally { setIsLoadingAsset(false); } }; // Helper function to format date for input (keeping for date-only fields) const formatDateForInput = (dateStr: string | null | undefined): string => { if (!dateStr) return ''; // Extract just "YYYY-MM-DD" from "YYYY-MM-DD HH:MM:SS" return dateStr.split(' ')[0]; }; // Function to populate form with asset data const populateFromAsset = (assetData: any) => { if (!assetData) return; setFormData(prev => ({ ...prev, asset: assetData.name || prev.asset, asset_name: assetData.asset_name || '', company: assetData.company || '', department: assetData.department || '', serial_number: assetData.custom_serial_number || '', asset_type: assetData.custom_asset_type || '', manufacturer: assetData.custom_manufacturer || '', supplier: assetData.supplier || '', custom_site_contractor: assetData.custom_site_contractor || '', custom_subcontractor: assetData.custom_subcontractor || '', model: assetData.custom_model || '', site_name: assetData.custom_site || '', // Service Agreement fields - auto-populate from asset custom_service_agreement: assetData.custom_service_agreement || '', custom_service_coverage: assetData.custom_service_coverage || '', custom_start_date: formatDateForInput(assetData.custom_start_date) || '', custom_end_date: formatDateForInput(assetData.custom_end_date) || '', custom_total_amount: assetData.custom_total_amount || 0, custom_location: assetData.location || assetData.custom_location || '', custom_building: assetData.custom_building || '', custom_room_no: assetData.custom_room_number || '', })); }; // Handler for Asset ID change const handleAssetIdChange = async (assetId: string) => { setFormData(prev => ({ ...prev, asset: assetId })); if (assetId) { const assetData = await fetchAssetDetails(assetId); if (assetData) { populateFromAsset(assetData); } } else { setFormData(prev => ({ ...prev, asset: '', asset_name: '', serial_number: '', asset_type: 'Non Biomedical', // Reset to default manufacturer: '', supplier: '', custom_site_contractor: '', custom_subcontractor: '', model: '', site_name: '', // Clear service agreement fields custom_service_agreement: '', custom_service_coverage: '', custom_start_date: '', custom_end_date: '', custom_total_amount: 0, custom_building: '', custom_room_no: '', custom_location: '', })); } }; // Handler for Serial Number search const handleSerialNumberSearch = async () => { if (!formData.serial_number) { toast.warning(t('workOrders.detail.pleaseEnterSerialNumberToSearch'), { position: "top-right", autoClose: 3000, icon: }); return; } const assetData = await fetchAssetBySerialNumber(formData.serial_number); if (assetData) { populateFromAsset(assetData); toast.success(t('workOrders.detail.assetFound', { name: assetData.asset_name || assetData.name }), { position: "top-right", autoClose: 3000, icon: }); } else { toast.error(t('workOrders.detail.noAssetFoundWithSerialNumber'), { position: "top-right", autoClose: 4000, icon: }); } }; // Handler for Serial Number blur const handleSerialNumberBlur = async () => { if (formData.serial_number && !formData.asset) { const assetData = await fetchAssetBySerialNumber(formData.serial_number); if (assetData) { populateFromAsset(assetData); } } }; // ============== FILE UPLOAD HANDLER ============== /** * Handle file upload for attachment field */ const handleFileUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // Check file size (max 10MB) const maxSize = 10 * 1024 * 1024; // 10MB if (file.size > maxSize) { toast.error(t('workOrders.detail.fileSizeExceeds'), { position: "top-right", autoClose: 4000, icon: }); return; } setIsUploading(true); try { const formDataUpload = new FormData(); formDataUpload.append('file', file); formDataUpload.append('is_private', '0'); formDataUpload.append('folder', 'Home/Attachments'); // If editing existing work order, attach to it if (!isNewWorkOrder && workOrderName) { formDataUpload.append('doctype', 'Work_Order'); formDataUpload.append('docname', workOrderName); } const baseUrl = API_CONFIG.BASE_URL || ''; const response = await fetch(`${baseUrl}/api/method/upload_file`, { method: 'POST', credentials: 'include', body: formDataUpload }); const result = await response.json(); if (result.message?.file_url) { setFormData(prev => ({ ...prev, custom_attachment: result.message.file_url })); toast.success(t('workOrders.detail.fileUploadedSuccessfully'), { position: "top-right", autoClose: 3000, icon: }); } else { throw new Error('Upload failed'); } } catch (err) { console.error('File upload error:', err); toast.error(t('workOrders.detail.failedToUploadFile'), { position: "top-right", autoClose: 4000, icon: }); } finally { setIsUploading(false); // Reset the input e.target.value = ''; } }; /** * Remove attachment */ const handleRemoveAttachment = () => { setFormData(prev => ({ ...prev, custom_attachment: '' })); toast.info(t('workOrders.detail.attachmentRemoved'), { position: "top-right", autoClose: 2000 }); }; /** * Get filename from URL */ const getFilenameFromUrl = (url: string): string => { if (!url) return ''; const parts = url.split('/'); return parts[parts.length - 1] || url; }; // ============== END FILE UPLOAD HANDLER ============== // Pre-populate form when creating from Asset useEffect(() => { if (isNewWorkOrder && isCreatingFromAsset && !isDuplicating) { const assetData = { asset: searchParams.get('asset') || '', asset_name: searchParams.get('asset_name') || '', asset_type: searchParams.get('asset_type') || 'Non Biomedical', manufacturer: searchParams.get('manufacturer') || '', supplier: searchParams.get('supplier') || '', serial_number: searchParams.get('serial_number') || '', department: searchParams.get('department') || '', custom_site_contractor: searchParams.get('site_contractor') || '', custom_subcontractor: searchParams.get('subcontractor') || '', company: searchParams.get('company') || '', site_name: searchParams.get('site_name') || '', custom_building: searchParams.get('building') || '', custom_room_no: searchParams.get('room_no') || '', custom_location: searchParams.get('location') || '', }; setFormData(prev => ({ ...prev, ...assetData, repair_status: 'Open', workflow_state: 'Draft', custom_priority_: 'Normal', failure_date: getCurrentDateTime(), // ✅ Set current datetime })); } }, [isNewWorkOrder, isCreatingFromAsset, isDuplicating, searchParams]); // Pre-populate from Support Issue (Work Control flow: /work-orders/new?from_issue=...) useEffect(() => { if (!isCreatingFromIssue || !fromIssueName) { setIssuePrefillResolved(true); return; } setIssuePrefillResolved(false); let cancelled = false; void issueService .getIssue(fromIssueName) .then((iss) => { if (cancelled) return; const subject = (iss.subject || '').trim(); const body = (iss.description || '').trim(); const descriptionFromIssue = subject && body ? `Subject: ${subject}\n\n${body}` : subject || body || ''; setFormData((prev) => ({ ...prev, issue: iss.name || fromIssueName, company: iss.company || prev.company, work_order_type: iss.issue_type || prev.work_order_type, custom_priority_: iss.priority ? mapIssuePriorityToWorkOrderCustomPriority(iss.priority) : prev.custom_priority_, project: iss.project || prev.project, description: descriptionFromIssue || prev.description, failure_date: buildFailureDateFromIssueOpening(iss.opening_date, iss.opening_time), })); }) .catch(() => { if (cancelled) return; toast.error(t('workOrders.detail.failedToLoadSupportIssue'), { position: 'top-right', autoClose: 5000, icon: , }); setFormData((prev) => ({ ...prev, issue: fromIssueName, })); }) .finally(() => { if (!cancelled) setIssuePrefillResolved(true); }); return () => { cancelled = true; }; }, [isCreatingFromIssue, fromIssueName, t]); // Store initial values only on FIRST load (for "Back To Controller" and restore logic) useEffect(() => { if (workOrder && !hasLoadedInitialData && !isNewWorkOrder && !isDuplicating) { setInitialWorkOrderType(workOrder.work_order_type || ''); setInitialSupervisor(workOrder.custom_assigned_supervisor || ''); setInitialEngineer(workOrder.custom_assigned_engineer || ''); setInitialTechnician(workOrder.assigned_technician || ''); setHasLoadedInitialData(true); console.log('Initial values stored:', { workOrderType: workOrder.work_order_type, supervisor: workOrder.custom_assigned_supervisor, engineer: workOrder.custom_assigned_engineer, technician: workOrder.assigned_technician }); } }, [workOrder, hasLoadedInitialData, isNewWorkOrder, isDuplicating]); useEffect(() => { if (workOrder) { if (!deleteStatusJustUpdated.current) { setWoDeleteStatus(workOrder.custom_delete_status ?? null); } deleteStatusJustUpdated.current = false; setFormData({ company: workOrder.company || '', work_order_type: workOrder.work_order_type || '', asset: workOrder.asset || '', asset_name: isDuplicating ? `${workOrder.asset_name} (Copy)` : (workOrder.asset_name || ''), description: workOrder.description || '', repair_status: isDuplicating ? 'Open' : (workOrder.repair_status || 'Open'), workflow_state: isDuplicating ? 'Draft' : (workOrder.workflow_state || 'Draft'), department: workOrder.department || '', custom_priority_: mapIssuePriorityToWorkOrderCustomPriority( workOrder.custom_priority_ || undefined ), asset_type: workOrder.asset_type || 'Non Biomedical', custom_type: workOrder.custom_type || 'Corrective', manufacturer: workOrder.manufacturer || '', supplier: workOrder.supplier || '', serial_number: workOrder.serial_number || '', model: workOrder.model || '', custom_site_contractor: workOrder.custom_site_contractor || '', custom_subcontractor: workOrder.custom_subcontractor || '', // ✅ Use datetime format for these fields failure_date: formatDateTimeForInput(workOrder.failure_date) || '', custom_deadline_date: formatDateForInput(workOrder.custom_deadline_date) || '', first_responded_on: formatDateTimeForInput(workOrder.first_responded_on) || '', completion_date: formatDateTimeForInput(workOrder.completion_date) || '', actions_performed: workOrder.actions_performed || '', stock_consumption: workOrder.stock_consumption || 0, stock_items: workOrder.stock_items || [], // Fields for workflow conditions site_name: workOrder.site_name || '', need_procurement: workOrder.need_procurement || 0, custom_assign_to_contractor: workOrder.custom_assign_to_contractor || '', assigned_technician: workOrder.assigned_technician || '', custom_assigned_engineer: workOrder.custom_assigned_engineer || '', custom_add_technicians: workOrder.custom_add_technicians || '', docstatus: workOrder.docstatus || 0, // New fields custom_assigned_supervisor: workOrder.custom_assigned_supervisor || '', total_hours_spent: workOrder.total_hours_spent || 0, custom_pending_reason: workOrder.custom_pending_reason || '', total_repair_cost: workOrder.total_repair_cost || 0, custom_travel_hour: workOrder.custom_travel_hour || '', // Service Agreement fields custom_service_agreement: workOrder.custom_service_agreement || '', custom_service_coverage: workOrder.custom_service_coverage || '', custom_start_date: formatDateForInput(workOrder.custom_start_date) || '', custom_end_date: formatDateForInput(workOrder.custom_end_date) || '', custom_total_amount: workOrder.custom_total_amount || 0, // New fields added custom_location: workOrder.custom_location || '', custom_extension_no: workOrder.custom_extension_no || '', custom_attachment: workOrder.custom_attachment || '', custom_attachment_on_close: workOrder.custom_attachment_on_close || '', // Additional new fields inspection: workOrder.inspection || '', custom_department_no: workOrder.custom_department_no || '', custom_reason: workOrder.custom_reason || '', custom_technical_department: workOrder.custom_technical_department || '', custom_room_no: workOrder.custom_room_no || '', custom_building: workOrder.custom_building || '', custom_civil_work_category: workOrder.custom_civil_work_category || '', issue: workOrder.issue || '', }); } }, [workOrder, isDuplicating]); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); }; // Handler for priority change - also recalculates deadline const handlePriorityChange = (e: React.ChangeEvent) => { const newPriority = e.target.value; setFormData(prev => ({ ...prev, custom_priority_: newPriority })); }; // ✅ Handler for failure date change - updated for datetime const handleFailureDateChange = (e: React.ChangeEvent) => { const newFailureDateTime = e.target.value; setFormData(prev => ({ ...prev, failure_date: newFailureDateTime })); }; // ✅ Handler for first responded on change - datetime const handleFirstRespondedOnChange = (e: React.ChangeEvent) => { const newDateTime = e.target.value; setFormData(prev => ({ ...prev, first_responded_on: newDateTime })); }; // ✅ Handler for completion date change - datetime const handleCompletionDateChange = (e: React.ChangeEvent) => { const newDateTime = e.target.value; setFormData(prev => ({ ...prev, completion_date: newDateTime })); }; // ✅ Button to set current datetime for Failure Date const handleSetFailureDateNow = () => { const now = getCurrentDateTime(); setFormData(prev => ({ ...prev, failure_date: now })); toast.info(t('workOrders.detail.failureDateSetToCurrentTime'), { position: "top-right", autoClose: 2000, icon: }); }; // ✅ Button to set current datetime for First Responded On const handleSetFirstRespondedOnNow = () => { const now = getCurrentDateTime(); setFormData(prev => ({ ...prev, first_responded_on: now })); toast.info(t('workOrders.detail.firstRespondedOnSetToCurrentTime'), { position: "top-right", autoClose: 2000, icon: }); }; // ✅ Button to set current datetime for Completion Date const handleSetCompletionDateNow = () => { const now = getCurrentDateTime(); setFormData(prev => ({ ...prev, completion_date: now })); toast.info(t('workOrders.detail.completionDateSetToCurrentTime'), { position: "top-right", autoClose: 2000, icon: }); }; // Handler for need procurement checkbox change - also recalculates deadline const handleNeedProcurementChange = (e: React.ChangeEvent) => { const newValue = e.target.checked ? 1 : 0; setFormData(prev => ({ ...prev, need_procurement: newValue })); }; // Handler for asset type change - clears fields when Non Biomedical is selected const handleAssetTypeChange = (val: string) => { if (val === 'Non Biomedical') { // Clear asset-related fields when Non Biomedical is selected setFormData(prev => ({ ...prev, asset_type: val, asset: '', asset_name: '', serial_number: '', manufacturer: '', supplier: '', model: '', })); } else { setFormData(prev => ({ ...prev, asset_type: val })); } }; // Handler for Work Order Type change - also triggers manager, engineer and technician filtering const handleWorkOrderTypeChange = (val: string) => { // Check if reverting to initial/original work order type if (val === initialWorkOrderType && initialWorkOrderType !== '') { // Restore original values when reverting back setFormData(prev => ({ ...prev, work_order_type: val, custom_assigned_supervisor: initialSupervisor, custom_assigned_engineer: initialEngineer, assigned_technician: initialTechnician, // ✅ Clear civil work category if not Civil works custom_civil_work_category: val === 'Civil works-الأعمال المدنية' ? prev.custom_civil_work_category : '' })); console.log('Reverted to original work_order_type, restored values:', { supervisor: initialSupervisor, engineer: initialEngineer, technician: initialTechnician }); } else { // Clear supervisor, engineer and technician when work order type changes to different value setFormData(prev => ({ ...prev, work_order_type: val, custom_assigned_supervisor: '', custom_assigned_engineer: '', custom_assign_to_contractor: '', assigned_technician: '', custom_civil_work_category: val === 'Civil works-الأعمال المدنية' ? prev.custom_civil_work_category : '' })); } }; // Handler for Assigned Supervisor change const handleSupervisorChange = (val: string) => { setFormData(prev => ({ ...prev, custom_assigned_supervisor: val })); }; // ============== VALIDATION BEFORE SAVE ============== /** * Check if user has Work Control role (or System Manager) */ const hasWorkControlRole = useMemo(() => { return userRoles.includes('Work Control') || userRoles.includes('System Manager'); }, [userRoles]); /** * Validate form data before saving * Implements: before_save validation for assigned_technician and assigned_supervisor * Only mandatory for Work Control role, not End user role */ const validateBeforeSave = (): boolean => { // Only validate supervisor/technician for Work Control role users (not End user) if (hasWorkControlRole && !isNewWorkOrder) { // Check if assigned_supervisor is empty if (!formData.custom_assigned_supervisor) { toast.error(t('workOrders.detail.assignedSupervisorMandatory'), { position: "top-right", autoClose: 5000, icon: }); return false; } // ✅ ADD THIS BLOCK - Check if assigned_engineer is empty if (!formData.custom_assigned_engineer) { toast.error(t('workOrders.detail.assignedEngineerMandatory'), { position: "top-right", autoClose: 5000, icon: }); return false; } if (currentState === 'Sent to Engineer' && !formData.assigned_technician) { toast.error(t('workOrders.detail.assignedTechnicianMandatory'), { position: "top-right", autoClose: 5000, icon: }); return false; } } // End user role - no validation required for supervisor/technician return true; }; // ============== CANCEL HANDLER (ERPNext style) ============== /** * Handle cancel - changes docstatus to 2 and workflow_state to 'Cancelled' */ const handleCancel = async () => { if (!workOrderName) return; setCancelling(true); try { // Update the document with cancelled state await updateWorkOrder(workOrderName, { workflow_state: 'Cancelled', docstatus: 2, repair_status: 'Cancelled' }); toast.success(t('workOrders.detail.workOrderCancelledSuccessfully'), { position: "top-right", autoClose: 3000, icon: }); setShowCancelConfirm(false); refetch(); // Refresh audit logs setTimeout(() => { fetchAuditLogs(); }, 500); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; toast.error(t('workOrders.detail.failedToCancel', { error: errorMessage }), { position: "top-right", autoClose: 6000, icon: }); } finally { setCancelling(false); } }; // ============== DELETE HANDLER ============== /** * Handle delete - removes the work order */ const handleDelete = async () => { if (!workOrderName) return; try { await apiService.apiCall(`/api/resource/Work_Order/${workOrderName}`, { method: 'DELETE' }); toast.success(t('workOrders.detail.workOrderDeletedSuccessfully'), { position: "top-right", autoClose: 3000, icon: }); navigate(-1); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; toast.error(t('workOrders.detail.failedToDelete', { error: errorMessage }), { position: "top-right", autoClose: 6000, icon: }); } }; // ============== END CANCEL/DELETE HANDLERS ============== const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.work_order_type) { toast.error(t('workOrders.detail.pleaseSelectWorkOrderType'), { position: "top-right", autoClose: 4000, icon: }); return; } if (assetIsMandatory && !formData.asset) { toast.error('Asset is required based on your location selection. Please select an Asset.', { position: "top-right", autoClose: 5000, icon: }); return; } // Run validation before save if (!validateBeforeSave()) { return; } // ✅ Prepare data with proper datetime format for Frappe (omit client-only keys) const { __islocal: _omitIsLocal, ...formPayload } = formData as typeof formData & { __islocal?: boolean }; const dataToSave = { ...formPayload, custom_priority_: mapIssuePriorityToWorkOrderCustomPriority(formData.custom_priority_), failure_date: formatDateTimeForFrappe(formData.failure_date), first_responded_on: formatDateTimeForFrappe(formData.first_responded_on), completion_date: formatDateTimeForFrappe(formData.completion_date), }; // Always persist Support Issue link on create when the form has it (not only when URL has from_issue). if (isNewWorkOrder) { const linkedIssue = String(formData.issue || fromIssueName || '').trim(); if (linkedIssue) { dataToSave.issue = linkedIssue; } } try { if (isNewWorkOrder || isDuplicating) { const newWorkOrder = await createWorkOrder(dataToSave); const successMessage = isDuplicating ? t('workOrders.detail.workOrderDuplicatedSuccessfully') : isCreatingFromIssue ? t('workOrders.detail.workOrderCreatedFromIssueSuccessfully') : isCreatingFromAsset ? t('workOrders.detail.workOrderCreatedFromAssetSuccessfully') : t('workOrders.detail.workOrderCreatedSuccessfully'); toast.success(successMessage, { position: "top-right", autoClose: 3000, icon: }); navigate(`/work-orders/${newWorkOrder.name}`); } else if (workOrderName) { await updateWorkOrder(workOrderName, dataToSave); toast.success(t('workOrders.detail.workOrderUpdatedSuccessfully'), { position: "top-right", autoClose: 3000, icon: }); setIsEditing(false); refetch(); // Refresh audit logs immediately without page refresh setTimeout(() => { fetchAuditLogs(); }, 500); // Small delay to ensure Version record is created } } catch (err) { console.error('Work order save error:', err); const errorMessage = err instanceof Error ? err.message : 'Unknown error'; const errorString = JSON.stringify(err); // Check if it's a timestamp mismatch error const isTimestampError = errorMessage.includes('TimestampMismatchError') || errorMessage.includes('Document has been modified') || errorMessage.includes('Please refresh') || errorString.includes('TimestampMismatchError'); if (isTimestampError) { toast.error(t('workOrders.detail.documentModifiedByAnotherUser'), { position: "top-right", autoClose: 4000, icon: }); // Refresh the document and retry await refetch(); toast.info(t('workOrders.detail.pleaseReviewLatestChanges'), { position: "top-right", autoClose: 5000, icon: }); } else { toast.error(t('workOrders.detail.failedToSaveWorkOrder', { error: errorMessage }), { position: "top-right", autoClose: 6000, icon: }); } } }; // Check Maintenance Manager role - simple version useEffect(() => { if (isNewWorkOrder) return; fetch('/api/method/asset_lite.api.user_roles.check_has_role', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ roles: 'Maintenance Manager' }) }) .then(res => res.json()) .then(data => { if (data.message?.has_role) { setIsMaintenanceManager(true); } }) .catch(err => console.error('Role check error:', err)); }, [isNewWorkOrder]); // Function to call assign_supervisor_or_technician API before workflow action // This mirrors Frappe's before_workflow_action client script behavior const callBeforeWorkflowAction = async (action: string): Promise<{ assigned_to: string | null } | null> => { if (!workOrderName || isNewWorkOrder) return null; // Only call for specific actions that need assignment const actionsNeedingAssignment = ['Apply', 'Send For Repair']; if (!actionsNeedingAssignment.includes(action)) { return null; } try { const response = await apiService.apiCall( `/api/method/assign_supervisor_or_technician`, { method: 'POST', body: JSON.stringify({ work_order: workOrderName, action: action, asset_type: formData.asset_type || workOrder?.asset_type || '' }) } ); return response?.message || null; } catch (err) { console.error('Error in before_workflow_action:', err); // Don't block workflow action if this fails, just log it return null; } }; // Workflow action handler with retry logic const handleWorkflowAction = async (action: string, nextState?: string) => { const actionsRequiringConfirmation = ['Reject', 'Cancel', 'Close']; if (actionsRequiringConfirmation.includes(action) && confirmAction?.action !== action) { setConfirmAction({ action, nextState: nextState || '' }); return; } setConfirmAction(null); // ✅ ADD THIS BLOCK - Auto-set completion_date when Cancel action is triggered if (action === 'Cancel' && workOrderName) { const nowDateTime = getCurrentDateTimeForFrappe(); try { await updateWorkOrder(workOrderName, { completion_date: nowDateTime }); setFormData(prev => ({ ...prev, completion_date: formatDateTimeForInput(nowDateTime) })); console.log('Auto-set completion_date on Cancel:', nowDateTime); } catch (err) { console.error('Error setting completion_date on Cancel:', err); // Don't block the cancel action if this fails } } // ✅ END BLOCK // ✅ Technical Report validation for "Send to Supervisor" from "Sent to Engineer" const currentWfState = workOrder?.workflow_state || formData.workflow_state || 'Draft'; if (action === 'Send to Supervisor' && currentWfState === 'Sent to Engineer') { const technicalReport = formData.actions_performed || ''; if (!technicalReport.trim()) { // Dismiss any existing toasts first to prevent conflicts toast.dismiss(); // Show error with unique ID and longer duration setTimeout(() => { toast.error( t('workOrders.detail.technicalReportMandatoryForSupervisor'), { position: "top-right", autoClose: 8000, hideProgressBar: false, closeOnClick: true, pauseOnHover: true, draggable: true, toastId: 'technical-report-required', icon: } ); }, 100); return; } } // ✅ END VALIDATION BLOCK // Show loading toast const loadingToastId = toast.loading(t('workOrders.detail.applyingAction', { action }), { position: "top-right" }); // ✅ NEW: Refetch to get latest document BEFORE any modifications await refetch(); // Call before_workflow_action logic const assignmentResult = await callBeforeWorkflowAction(action); if (assignmentResult?.assigned_to) { toast.info(t('workOrders.detail.assignedTo', { name: assignmentResult.assigned_to }), { position: "top-right", autoClose: 4000, icon: }); // ✅ NEW: Refetch again after assignment to get updated timestamp await refetch(); } // ✅ NEW: Retry logic for timestamp mismatch let success = false; let retryCount = 0; const maxRetries = 3; while (!success && retryCount < maxRetries) { try { success = await applyAction(action, nextState); break; } catch (err: any) { const errorMsg = err?.message || String(err); const isTimestampError = errorMsg.includes('TimestampMismatchError') || errorMsg.includes('Document has been modified') || errorMsg.includes('Please refresh'); if (isTimestampError && retryCount < maxRetries - 1) { retryCount++; console.log(`Timestamp mismatch, retrying... (attempt ${retryCount})`); await refetch(); // Get latest document await new Promise(resolve => setTimeout(resolve, 500)); // Small delay } else { throw err; } } } // Dismiss loading toast toast.dismiss(loadingToastId); if (success) { toast.success(t('workOrders.detail.actionCompletedSuccessfully', { action }), { position: "top-right", autoClose: 3000, icon: }); refetch(); setTimeout(() => fetchAuditLogs(), 500); // // Workflow action handler // const handleWorkflowAction = async (action: string, nextState?: string) => { // const actionsRequiringConfirmation = ['Reject', 'Cancel', 'Close']; // if (actionsRequiringConfirmation.includes(action) && confirmAction?.action !== action) { // setConfirmAction({ action, nextState: nextState || '' }); // return; // } // setConfirmAction(null); // // Show loading toast // const loadingToastId = toast.loading(`Applying action "${action}"...`, { // position: "top-right" // }); // // Call before_workflow_action logic (assign supervisor/technician) // // This mirrors Frappe's before_workflow_action client script // const assignmentResult = await callBeforeWorkflowAction(action); // if (assignmentResult?.assigned_to) { // toast.info(`Assigned to: ${assignmentResult.assigned_to}`, { // position: "top-right", // autoClose: 4000, // icon: // }); // } // const success = await applyAction(action, nextState); // // Dismiss loading toast // toast.dismiss(loadingToastId); // if (success) { // toast.success(`Action "${action}" completed successfully!`, { // position: "top-right", // autoClose: 3000, // icon: // }); // refetch(); // Refresh work order data to get updated assignments // // Refresh audit logs immediately without page refresh // setTimeout(() => { // fetchAuditLogs(); // }, 500); // Small delay to ensure Version record is created } else { const errorMsg = workflowError || 'Please try again.'; const isTimestampError = errorMsg.includes('TimestampMismatchError') || errorMsg.includes('Document has been modified') || errorMsg.includes('Please refresh'); if (isTimestampError) { toast.error(t('workOrders.detail.documentModifiedRefreshing'), { position: "top-right", autoClose: 4000, icon: }); await refetch(); toast.info(t('workOrders.detail.tryActionAgainAfterReview'), { position: "top-right", autoClose: 5000, icon: }); } else { toast.error(t('workOrders.detail.failedToApplyAction', { action, error: errorMsg }), { position: "top-right", autoClose: 6000, icon: }); } } }; const troubleshootGuideCompleted = useMemo( () => technicalReportHasGuideCompleted(workOrder?.actions_performed ?? formData.actions_performed), [workOrder?.actions_performed, formData.actions_performed], ); if (loading || (isCreatingFromIssue && !issuePrefillResolved)) { return (

{isCreatingFromIssue && !issuePrefillResolved ? t('workOrders.detail.loadingSupportIssue') : t('workOrders.loadingDetails')}

); } if (error && !isNewWorkOrder && !isDuplicating) { return (

{t('workOrders.detail.errorLabel')}: {error}

); } const getPageTitle = () => { if (isDuplicating) return t('workOrders.detail.duplicateWorkOrder'); if (isCreatingFromIssue) return t('workOrders.detail.createFromSupportIssue'); if (isCreatingFromAsset) return t('workOrders.detail.createFromAsset'); if (isNewWorkOrder) return t('workOrders.detail.newWorkOrder'); return t('workOrders.detail.workOrderDetails'); }; const currentWorkflowState = workOrder?.workflow_state || formData.workflow_state || 'Draft'; const stateStyle = getStateStyle(currentWorkflowState); // Check if editing is allowed based on workflow and roles const currentDocstatus = workOrder?.docstatus ?? formData.docstatus ?? 0; const currentState = workOrder?.workflow_state || formData.workflow_state || 'Draft'; // Check if user has Work Control role const hasWorkControlRoleForEdit = userRoles.includes('Work Control'); // Check if user has Contractor Engineer role const hasContractorEngineerRole = userRoles.includes('Contractor Engineer'); // Check if user has Contractor Supervisor role (should NOT be able to edit unless also System Manager) const hasContractorSupervisorRole = userRoles.includes('Contractor Supervisor'); // Contractor Supervisor cannot edit unless they also have System Manager role const isContractorSupervisorOnly = hasContractorSupervisorRole && !isSystemManager; const canEditBasedOnWorkflow = isNewWorkOrder || (isSystemManager && currentDocstatus === 0) || (isMaintenanceManager && currentState === 'Sent to Team Leader') || // Allow Work Control to edit in Draft state even if Apply is hidden // (hasWorkControlRoleForEdit && currentState === 'Draft' && currentDocstatus === 0) || (hasWorkControlRoleForEdit && (currentState === 'Draft' || currentState === 'Sent to Work Control') && currentDocstatus === 0) || // Allow Contractor Engineer to edit in "Sent to Engineer" state (to assign technician) (hasContractorEngineerRole && currentState === 'Sent to Engineer' && currentDocstatus === 0) || (!workflowLoading && transitions.length > 0); // Helper function to get deadline days text based on priority const getDeadlineDaysText = () => { const priority = formData.custom_priority_ || 'Normal'; const hasProcurement = formData.need_procurement === 1; if (hasProcurement) { return `${priority} priority + Procurement: +30 days`; } switch (priority) { case 'Urgent': return 'Urgent priority: +1 day'; case 'Medium': return 'Medium priority: +3 days'; case 'Normal': default: return 'Normal priority: +5 days'; } }; // ============== AUDIT LOG HELPERS ============== /** * Format field name for display (convert snake_case to Title Case) */ const formatFieldName = (fieldName: string): string => { if (!fieldName) return ''; return fieldName .replace(/^custom_/, '') .replace(/_/g, ' ') .replace(/\b\w/g, (char) => char.toUpperCase()); }; /** * Format value for display */ const formatValue = (value: any): string => { if (value === null || value === undefined) return '(empty)'; if (value === '') return '(empty)'; if (value === 0) return '0'; if (value === 1) return '1'; if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (typeof value === 'object') return JSON.stringify(value); return String(value); }; /** * Format date for audit log display */ const formatAuditDate = (dateStr: string): string => { if (!dateStr) return ''; const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return 'Just now'; if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`; if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, hour: '2-digit', minute: '2-digit' }); }; /** * Get username from email (extract part before @) */ const formatUsername = (email: string): string => { if (!email) return 'Unknown'; const atIndex = email.indexOf('@'); if (atIndex === -1) return email; return email.substring(0, atIndex); }; /** * Get color class for field change based on field name */ const getChangeColor = (fieldName: string): string => { const lowercaseField = fieldName.toLowerCase(); if (lowercaseField.includes('status') || lowercaseField.includes('state')) { return 'text-purple-600 dark:text-purple-400'; } if (lowercaseField.includes('date')) { return 'text-blue-600 dark:text-blue-400'; } if (lowercaseField.includes('technician') || lowercaseField.includes('supervisor') || lowercaseField.includes('assigned')) { return 'text-green-600 dark:text-green-400'; } return 'text-gray-600 dark:text-gray-400'; }; return (
{/* Toast Container for notifications */} {/* Duplicate Work Order Warning Modal */} {showDuplicateWarning && duplicateWorkOrders.length > 0 && (

{t('workOrders.detail.existingWorkOrderFound')}

{duplicateCheckType === 'asset' ? ( t('workOrders.detail.workOrderExistsForAsset') ) : ( t('workOrders.detail.workOrderExistsForTypeAndRoom') )}

{t('workOrders.detail.existingWorkOrders')}

{duplicateWorkOrders.map((wo) => (
{wo.name}

{t('workOrders.detail.created')}: {formatDateTimeForDisplay(wo.creation)}

{wo.workflow_state}
))}

{t('workOrders.detail.proceedWithNewWO')}

)} {/* ✅ Cancel Confirmation Modal */} {showCancelConfirm && (

{t('workOrders.cancelWorkOrder')}

{t('workOrders.cancelConfirmMessage')}

)} {/* ✅ Delete Confirmation Modal */} {showDeleteConfirm && (

{t('workOrders.deleteWorkOrder')}

{t('workOrders.deleteConfirmMessage')}

)} {/* Multi-Technician Selection Modal */} {showTechnicianModal && (

{t('workOrders.detail.selectAdditionalTechnicians')}

{/* Search Input */}
setTechnicianSearchQuery(e.target.value)} className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
{/* Selected Count */}
{t('workOrders.detail.techniciansSelected', { count: selectedAdditionalTechnicians.length })} {selectedAdditionalTechnicians.length > 0 && ( )}
{/* Technician List */}
{loadingTechnicians ? (
{t('workOrders.detail.loadingTechnicians')}
) : filteredAvailableTechnicians.length === 0 ? (

{technicianSearchQuery ? t('workOrders.detail.noTechniciansMatchSearch') : t('workOrders.detail.noTechniciansFound')}

) : (
{filteredAvailableTechnicians.map((technician) => { const isSelected = selectedAdditionalTechnicians.includes(technician.name); const isPrimaryTechnician = formData.assigned_technician === technician.name; return ( ); })}
)}
{/* Action Buttons */}
)} {/* Technician Selection Confirmation Modal */} {showTechnicianConfirm && (

Confirm Technician Assignment

Are you sure you want to assign this work order to the following technician(s)?

{/* Selected Technicians List */}

Selected Technicians ({pendingTechnicians.length}):

{pendingTechnicians.map((tech) => { const technicianInfo = availableTechnicians.find(t => t.name === tech); const displayName = technicianInfo?.full_name || tech; const email = tech; return (

{displayName}

{email}

); })}
)} {/* ============== FEEDBACK MODAL ============== */} {showFeedbackModal && (
{/* Modal Header */}

{feedbackMode === 'give' ? `⭐ ${t('workOrders.detail.giveFeedback')}` : feedbackMode === 'edit' ? `✏️ ${t('workOrders.detail.editFeedback')}` : `⭐ ${t('workOrders.detail.feedbackDetails')}`}

{t('workOrders.detail.workOrder')} {workOrderName}

{/* Feedback By */}

{t('workOrders.detail.feedbackBy')}

{currentUserFullName || currentUser}

{/* Rating Parameters */}
{feedbackRatings.map((item, index) => (
{/* Star Rating */}
{[1, 2, 3, 4, 5].map((star) => { const starValue = star * 0.2; // 0.2 per star const isFilled = item.rating >= starValue; const isHalf = !isFilled && item.rating >= starValue - 0.1; const isDisabled = feedbackMode === 'view'; return ( ); })} {item.rating > 0 ? `${(item.rating * 5).toFixed(1)} / 5` : t('workOrders.detail.notRated')}
))}
{/* Overall Rating Display */} {(feedbackMode === 'view' || feedbackMode === 'edit') && feedbackData?.overall > 0 && (

{t('workOrders.detail.overallSatisfaction')}

{[1, 2, 3, 4, 5].map((star) => ( = star * 0.2 ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-500' }`} > ★ ))}
{(feedbackData.overall * 5).toFixed(1)} / 5
)} {/* Live Overall Preview for give/edit mode */} {(feedbackMode === 'give' || feedbackMode === 'edit') && ( (() => { const liveOverall = calculateOverall(feedbackRatings); if (liveOverall <= 0) return null; return (

{t('workOrders.detail.overallRatingPreview')}

{[1, 2, 3, 4, 5].map((star) => ( = star * 0.2 ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-500' }`} > ★ ))}
{(liveOverall * 5).toFixed(1)} / 5
); })() )} {/* Action Buttons */}
{feedbackMode === 'view' ? ( <> {isWorkOrderOwner && ( )} ) : ( <> )}
)} {/* ============== END FEEDBACK MODAL ============== */} {/* Header */}
{/* Workflow State Badge */} {/* {!isNewWorkOrder && ( {currentWorkflowState} )} */} {/* Repair Status Badge in Header */} {!isNewWorkOrder && ( {workOrder?.repair_status || formData.repair_status || 'Open'} )} {/* ✅ Show Cancelled badge */} {/* {!isNewWorkOrder && isCancelled && ( {t('workOrders.detail.cancelled')} )} */} {isCreatingFromAsset && ( {t('workOrders.detail.linkedFromAsset')} {assetIdFromParams} )} {isLoadingAsset && ( {t('workOrders.detail.loadingAssetDetails')} )}
{/* Service Report Print Button */} {!isNewWorkOrder && ( )} {!isNewWorkOrder && workOrderName && currentWorkflowState === 'Repair InProgress' && ( )} {/* ✅ Show Edit button only if not submitted and not cancelled */} {/* Hide for Contractor Supervisor unless they also have System Manager role */} {/* {!isNewWorkOrder && !isEditing && !isFormReadOnly && canEditBasedOnWorkflow && !isContractorSupervisorOnly && ( */} {!isNewWorkOrder && !isEditing && !isFormReadOnly && !isDeletePending && canEditBasedOnWorkflow && !isContractorSupervisorOnly && rolesLoaded && ( )} {/* ✅ Show Cancel button only if submitted (docstatus = 1) and user has permission */} {/* {!isNewWorkOrder && !isEditing && isSubmitted && canDelete && ( */} {/* ✅ // NEW - Cancel button: show only when actual docstatus is 1 */} {!isNewWorkOrder && !isEditing && (workOrder?.docstatus === 1) && canDelete && ( )} {/* ✅ Show Delete button only if: - Draft (docstatus = 0) AND user has permission - OR Cancelled (docstatus = 2) AND user has permission */} {/* {!isNewWorkOrder && !isEditing && (workOrder?.docstatus === 0 || workOrder?.docstatus === 2) && ( { refetch(); }} /> )} */} {isEditing && ( <> )}
{/* Asset Link Info Banner */} {isCreatingFromAsset && isNewWorkOrder && (

{t('workOrders.createFromAsset')}

{t('workOrders.detail.assetInfoPrefilled')} {formData.asset_name || assetIdFromParams}. {t('assets.pleaseSelectWorkOrderType')}

)}
{/* Left Column - Main Info */}
{/* Asset Information Section */}

Asset Information

{isCreatingFromAsset && ( {t('workOrders.detail.fromAsset')} )}
{/* First Row: Hospital, Asset Type, Technical Department */}
setFormData({ ...formData, company: val, department: '' })} disabled={!isEditing} filters={{ domain: 'Healthcare' }} />
{/*
*/}
{filteredManagers.length > 0 && (

{t('workOrders.detail.supervisorsAvailableForType', { count: filteredManagers.length })}

)}
{/* ✅ ADD THIS BLOCK - Civil Work Category - Only show when Technical Department is Civil works */} {formData.work_order_type === 'Civil works-الأعمال المدنية' && (
)}
{/*
{formData.asset && (

✓ Asset details auto-populated

)}
*/}
{(() => { // Build dynamic filters based on filled location fields const assetFilters: Record = { docstatus: 1 }; if (formData.company) assetFilters['company'] = formData.company; if (formData.custom_building) assetFilters['custom_building'] = formData.custom_building; if (formData.department) assetFilters['department'] = formData.department; if (formData.custom_room_no) assetFilters['custom_room_number'] = formData.custom_room_no; return ( <> {formData.asset && (

✓ Asset details auto-populated

{isEditing && ( )}
)} {assetIsMandatory && !formData.asset && (

Asset is required based on your location selection

)} {!formData.asset && (formData.custom_building || formData.department || formData.custom_room_no) && !assetIsMandatory && assetFilterCount > 0 && (

{assetFilterCount} asset(s) available for selected location

)} ); })()}
{formData.asset && (
)} {/* Extension No - Link to Extension Directory doctype */}
setFormData({ ...formData, custom_extension_no: val })} disabled={!isEditing} allowQuickCreate={true} />
{/* Building - Always visible */}
{!formData.custom_building && isEditing && (

{t('workOrders.detail.selectBuildingFirst')}

)}
{/* Department Name - Visible only if Building is filled OR Department already has value */} {shouldShowDepartmentField() && (
0 ? { name: ['in', filteredDepartmentsForBuilding] } : departmentFilters } allowQuickCreate={true} /> {formData.custom_building && filteredDepartmentsForBuilding.length === 0 && !loadingLocationData && (

No departments mapped to this building

)} {formData.custom_building && filteredDepartmentsForBuilding.length > 0 && (

{filteredDepartmentsForBuilding.length} department(s) available for this building

)} {!formData.department && formData.custom_building && isEditing && (

Select Department to enable Room No

)}
)} {/* Room No - Visible only if Department is filled OR Room No already has value */} {shouldShowRoomNoField() && (
{ const roomFilters: Record = {}; if (formData.custom_building) { roomFilters['building'] = formData.custom_building; } if (formData.department) { roomFilters['department'] = formData.department; } return roomFilters; })() } allowQuickCreate={true} /> {loadingLocationData && (

Loading...

)} {!loadingLocationData && (formData.custom_building || formData.department) && filteredRoomNosForDepartment.length === 0 && (

No rooms mapped to {formData.custom_building && formData.department ? 'this building + department' : formData.custom_building ? 'this building' : 'this department'}

)} {!loadingLocationData && (formData.custom_building || formData.department) && filteredRoomNosForDepartment.length > 0 && (

{filteredRoomNosForDepartment.length} room(s) available {formData.custom_building && !formData.department && ' (filtered by building)'} {!formData.custom_building && formData.department && ' (filtered by department)'} {formData.custom_building && formData.department && ' (filtered by building + department)'}

)} {!formData.custom_room_no && formData.department && isEditing && (

Select Room No to enable Location

)}
)} {/* Location - Visible only if Room No is filled OR Location already has value */} {shouldShowLocationField() && (
setFormData({ ...formData, custom_location: val })} disabled={!isEditing} allowQuickCreate={true} /> {loadingLocationData && (

Loading location data...

)} {(formData.custom_building || formData.department) && formData.custom_room_no && formData.custom_location && (

✓ Location auto-set from Infrastructure mapping

)}
)} {/* Inspection - Only show if there's a value (read-only with link) */} {formData.inspection && (
{formData.inspection}
)} {/* Attachment Field - Moved to Asset Information section */}
{/* Show existing attachment */} {formData.custom_attachment ? ( ) : ( /* Upload button when no attachment */ isEditing ? ( ) : (

{t('workOrders.detail.noAttachment')}

) )}
{/* Closing Attachment Field */} {['Pending Approval', 'Approved', 'Rejected', 'Closed'].includes(currentWorkflowState) && (
{/* Show existing closing attachment */} {formData.custom_attachment_on_close ? (
{isEditing && ( )}
) : ( /* Upload button when no closing attachment */ isEditing ? ( ) : (

{t('workOrders.detail.noClosingAttachment')}

) )}
)} {/* Other Asset fields - Hidden for Non Biomedical */} {!isNonBiomedical && ( <>
{formData.asset && (

✓ Asset details auto-populated

)}
{isEditing && ( )}
{formData.asset && (
)}
setFormData({ ...formData, manufacturer: val })} disabled={!isEditing} />
setFormData({ ...formData, supplier: val })} disabled={!isEditing} />
)}
{/* Work Order Information */}

{t('workOrders.detail.workOrderInformation')}

{/* Other Reason - Only show when Pending Reason is "Other" */} {formData.custom_pending_reason === 'Other' && (
)}
{/* Service Agreement Section - Hidden for Non Biomedical */} {!isNonBiomedical && (

{t('workOrders.detail.serviceAgreementDetails')}

)} {/* Description Section */}

{t('workOrders.detail.description')}

setFormData((prev) => ({ ...prev, issue: val }))} disabled={!isEditing} />