Seera-Unified-UI/asm_app/src/pages/WorkOrderDetail.tsx

6856 lines
298 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: <FaExclamationTriangle />
});
}
};
const [isMaintenanceManager, setIsMaintenanceManager] = useState(false);
// User roles state for workflow transition filtering
const [userRoles, setUserRoles] = useState<string[]>([]);
const [rolesLoaded, setRolesLoaded] = useState(false);
const [currentUser, setCurrentUser] = useState<string>('');
// State for multi-technician selection modal
const [showTechnicianModal, setShowTechnicianModal] = useState(false);
const [selectedAdditionalTechnicians, setSelectedAdditionalTechnicians] = useState<string[]>([]);
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<string | null>(null);
const [pendingTechnicians, setPendingTechnicians] = useState<string[]>([]);
// 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<DeleteStatus>(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<string[]>([]);
const [filteredTechnicians, setFilteredTechnicians] = useState<string[]>([]);
const [filteredEngineers, setFilteredEngineers] = useState<string[]>([]);
// Track initial values to detect changes for "Back To Controller" button and restore on revert
const [hasLoadedInitialData, setHasLoadedInitialData] = useState(false);
const [initialWorkOrderType, setInitialWorkOrderType] = useState<string>('');
const [initialSupervisor, setInitialSupervisor] = useState<string>('');
const [initialEngineer, setInitialEngineer] = useState<string>('');
const [initialTechnician, setInitialTechnician] = useState<string>('');
// State for cascading location filters
const [filteredDepartmentsForBuilding, setFilteredDepartmentsForBuilding] = useState<string[]>([]);
const [filteredRoomNosForDepartment, setFilteredRoomNosForDepartment] = useState<string[]>([]);
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<AuditLogEntry[]>([]);
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<any>(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<CreateWorkOrderData & {
stock_consumption?: number;
stock_items?: StockItem[];
site_name?: string;
need_procurement?: number;
custom_assign_to_contractor?: string;
assigned_technician?: string;
custom_assigned_engineer?: string;
docstatus?: number;
// New fields
custom_assigned_supervisor?: string;
total_hours_spent?: number;
custom_pending_reason?: string;
total_repair_cost?: number;
// Service Agreement fields
custom_service_agreement?: string;
custom_service_coverage?: string;
custom_start_date?: string;
custom_end_date?: string;
custom_total_amount?: number;
// New fields added
custom_location?: string;
custom_extension_no?: string;
custom_attachment?: string;
custom_attachment_on_close?: string;
// Additional new fields
inspection?: string;
custom_department_no?: string;
custom_technical_department?: string;
custom_add_technicians?: string;
custom_room_no?: string;
custom_building?: string;
custom_type?: string;
custom_civil_work_category?: string;
issue?: string;
}>({
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<number> => {
try {
const response = await apiService.apiCall<any>(
`/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<any>(
`/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<any>(
`/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<any>(
`/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: <FaCheckCircle />
});
}
} 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: <FaClock />
});
// 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: <FaCheckCircle />
});
// 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<any>(
`/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<any>(
`/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<any>(
`/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: <FaExclamationTriangle />
});
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: <FaCheckCircle />
});
} 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: <FaTimesCircle />
});
} 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: <FaCheckCircle />
});
} 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: <FaTimesCircle />
});
} 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<string, string> = {
'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<any>(
`/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<any>(
`/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<string>();
// Fetch each team document and collect members
const promises = response.data.map(async (record: any) => {
try {
const teamResponse = await apiService.apiCall<any>(
`/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<any>(
`/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<string>();
// 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<any>(
`/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<any>(
`/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<any>(
`/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: <FaCheckCircle />
});
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: <FaExclamationTriangle />
});
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: <FaCheckCircle />
});
};
/**
* 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<string, any>);
// 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<string, WorkflowTransition[]> = {};
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<Record<number, string>>({});
// Function to fetch available stock from Bin doctype
const fetchAvailableStock = async (itemCode: string, warehouse: string): Promise<number> => {
if (!itemCode || !warehouse) return 0;
try {
const response = await apiService.apiCall<any>(
`/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<number> => {
if (!itemCode) return 0;
try {
const response = await apiService.apiCall<any>(
`/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<string> => {
if (!itemCode) return DEFAULT_WAREHOUSE;
try {
const response = await apiService.apiCall<any>(
`/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: <FaExclamationTriangle />,
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: <FaExclamationTriangle />,
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<Record<string, any>>({});
// Update department filters when company changes
useEffect(() => {
const filters: Record<string, any> = {};
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<any>(`/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<any>(
`/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: <FaExclamationTriangle />
});
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: <FaCheckCircle />
});
} else {
toast.error(t('workOrders.detail.noAssetFoundWithSerialNumber'), {
position: "top-right",
autoClose: 4000,
icon: <FaTimesCircle />
});
}
};
// 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<HTMLInputElement>) => {
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: <FaTimesCircle />
});
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: <FaCheckCircle />
});
} 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: <FaTimesCircle />
});
} 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: <FaExclamationTriangle />,
});
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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Handler for priority change - also recalculates deadline
const handlePriorityChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newPriority = e.target.value;
setFormData(prev => ({ ...prev, custom_priority_: newPriority }));
};
// ✅ Handler for failure date change - updated for datetime
const handleFailureDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFailureDateTime = e.target.value;
setFormData(prev => ({ ...prev, failure_date: newFailureDateTime }));
};
// ✅ Handler for first responded on change - datetime
const handleFirstRespondedOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newDateTime = e.target.value;
setFormData(prev => ({ ...prev, first_responded_on: newDateTime }));
};
// ✅ Handler for completion date change - datetime
const handleCompletionDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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: <FaClock />
});
};
// ✅ 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: <FaClock />
});
};
// ✅ 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: <FaClock />
});
};
// Handler for need procurement checkbox change - also recalculates deadline
const handleNeedProcurementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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: <FaTimesCircle />
});
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: <FaTimesCircle />
});
return false;
}
if (currentState === 'Sent to Engineer' && !formData.assigned_technician) {
toast.error(t('workOrders.detail.assignedTechnicianMandatory'), {
position: "top-right",
autoClose: 5000,
icon: <FaTimesCircle />
});
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: <FaCheckCircle />
});
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: <FaTimesCircle />
});
} 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: <FaCheckCircle />
});
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: <FaTimesCircle />
});
}
};
// ============== 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: <FaTimesCircle />
});
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: <FaTimesCircle />
});
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: <FaCheckCircle />
});
navigate(`/work-orders/${newWorkOrder.name}`);
} else if (workOrderName) {
await updateWorkOrder(workOrderName, dataToSave);
toast.success(t('workOrders.detail.workOrderUpdatedSuccessfully'), {
position: "top-right",
autoClose: 3000,
icon: <FaCheckCircle />
});
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: <FaExclamationTriangle />
});
// Refresh the document and retry
await refetch();
toast.info(t('workOrders.detail.pleaseReviewLatestChanges'), {
position: "top-right",
autoClose: 5000,
icon: <FaInfoCircle />
});
} else {
toast.error(t('workOrders.detail.failedToSaveWorkOrder', { error: errorMessage }), {
position: "top-right",
autoClose: 6000,
icon: <FaTimesCircle />
});
}
}
};
// 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<any>(
`/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: <FaExclamationTriangle />
}
);
}, 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: <FaCheckCircle />
});
// ✅ 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: <FaCheckCircle />
});
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: <FaCheckCircle />
// });
// }
// 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: <FaCheckCircle />
// });
// 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: <FaExclamationTriangle />
});
await refetch();
toast.info(t('workOrders.detail.tryActionAgainAfterReview'), {
position: "top-right",
autoClose: 5000,
icon: <FaInfoCircle />
});
} else {
toast.error(t('workOrders.detail.failedToApplyAction', { action, error: errorMsg }), {
position: "top-right",
autoClose: 6000,
icon: <FaTimesCircle />
});
}
}
};
const troubleshootGuideCompleted = useMemo(
() => technicalReportHasGuideCompleted(workOrder?.actions_performed ?? formData.actions_performed),
[workOrder?.actions_performed, formData.actions_performed],
);
if (loading || (isCreatingFromIssue && !issuePrefillResolved)) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">
{isCreatingFromIssue && !issuePrefillResolved
? t('workOrders.detail.loadingSupportIssue')
: t('workOrders.loadingDetails')}
</p>
</div>
</div>
);
}
if (error && !isNewWorkOrder && !isDuplicating) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">{t('workOrders.detail.errorLabel')}: {error}</p>
<button
onClick={() => navigate(-1)}
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
>
{t('workOrders.detail.backToList')}
</button>
</div>
</div>
);
}
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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
{/* Toast Container for notifications */}
<ToastContainer
position="top-right"
autoClose={4000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
transition={Bounce}
/>
{/* Duplicate Work Order Warning Modal */}
{showDuplicateWarning && duplicateWorkOrders.length > 0 && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-lg w-full mx-4 shadow-xl">
<div className="flex items-start gap-3 mb-4">
<FaExclamationTriangle className="text-orange-500 text-2xl mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
{t('workOrders.detail.existingWorkOrderFound')}
</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1 text-sm">
{duplicateCheckType === 'asset' ? (
t('workOrders.detail.workOrderExistsForAsset')
) : (
t('workOrders.detail.workOrderExistsForTypeAndRoom')
)}
</p>
</div>
</div>
<div className="mb-4 max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">{t('workOrders.detail.existingWorkOrders')}</p>
<div className="space-y-2">
{duplicateWorkOrders.map((wo) => (
<div key={wo.name} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<div>
<a href={`/asm_app/work-orders/${wo.name}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 font-medium hover:underline flex items-center gap-1">
{wo.name} <FaExternalLinkAlt size={10} />
</a>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('workOrders.detail.created')}: {formatDateTimeForDisplay(wo.creation)}</p>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
wo.workflow_state === 'Draft' ? 'bg-gray-100 text-gray-700' :
wo.workflow_state === 'Repair InProgress' ? 'bg-blue-100 text-blue-700' :
'bg-purple-100 text-purple-700'
}`}>{wo.workflow_state}</span>
</div>
))}
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t('workOrders.detail.proceedWithNewWO')}</p>
<div className="flex justify-end gap-3">
<button onClick={handleCancelDuplicate} className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg text-sm font-medium">{t('common.cancel')}</button>
<button onClick={handleProceedWithDuplicate} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium flex items-center gap-2">
<FaCheckCircle size={14} /> {t('workOrders.detail.proceedAnyway')}
</button>
</div>
</div>
</div>
)}
{/* ✅ Cancel Confirmation Modal */}
{showCancelConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<div className="flex items-start gap-3 mb-4">
<FaBan className="text-orange-500 text-xl mt-0.5" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{t('workOrders.cancelWorkOrder')}</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{t('workOrders.cancelConfirmMessage')}
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowCancelConfirm(false)}
className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg"
disabled={cancelling}
>
{t('workOrders.detail.noGoBack')}
</button>
<button
onClick={handleCancel}
disabled={cancelling}
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
>
{cancelling ? (
<>
<FaSpinner className="animate-spin" />
{t('workOrders.detail.cancelling')}
</>
) : (
<>
<FaBan />
{t('workOrders.detail.yesCancel')}
</>
)}
</button>
</div>
</div>
</div>
)}
{/* ✅ Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<div className="flex items-start gap-3 mb-4">
<FaExclamationTriangle className="text-red-500 text-xl mt-0.5" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{t('workOrders.deleteWorkOrder')}</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{t('workOrders.deleteConfirmMessage')}
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleDelete}
disabled={saving}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
>
{saving ? (
<>
<FaSpinner className="animate-spin" />
{t('workOrders.detail.deleting')}
</>
) : (
<>
<FaTrash />
{t('common.delete')}
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Multi-Technician Selection Modal */}
{showTechnicianModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<FaUser className="text-blue-500" />
{t('workOrders.detail.selectAdditionalTechnicians')}
</h3>
<button
onClick={() => setShowTechnicianModal(false)}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded"
>
<FaTimes size={18} />
</button>
</div>
{/* Search Input */}
<div className="mb-4">
<div className="relative">
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={14} />
<input
type="text"
placeholder={t('workOrders.detail.searchTechnicians')}
value={technicianSearchQuery}
onChange={(e) => 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"
/>
</div>
</div>
{/* Selected Count */}
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">
{t('workOrders.detail.techniciansSelected', { count: selectedAdditionalTechnicians.length })}
</span>
{selectedAdditionalTechnicians.length > 0 && (
<button
onClick={() => setSelectedAdditionalTechnicians([])}
className="text-xs text-red-500 hover:text-red-700"
>
{t('workOrders.detail.clearAll')}
</button>
)}
</div>
{/* Technician List */}
<div className="flex-1 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg">
{loadingTechnicians ? (
<div className="flex items-center justify-center py-8">
<FaSpinner className="animate-spin text-blue-500 mr-2" />
<span className="text-sm text-gray-500">{t('workOrders.detail.loadingTechnicians')}</span>
</div>
) : filteredAvailableTechnicians.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p className="text-sm">
{technicianSearchQuery ? t('workOrders.detail.noTechniciansMatchSearch') : t('workOrders.detail.noTechniciansFound')}
</p>
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredAvailableTechnicians.map((technician) => {
const isSelected = selectedAdditionalTechnicians.includes(technician.name);
const isPrimaryTechnician = formData.assigned_technician === technician.name;
return (
<label
key={technician.name}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${
isSelected ? 'bg-blue-50 dark:bg-blue-900/20' : ''
} ${isPrimaryTechnician ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => !isPrimaryTechnician && handleTechnicianToggle(technician.name)}
disabled={isPrimaryTechnician}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{technician.full_name || technician.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{technician.name}
</p>
</div>
{isPrimaryTechnician && (
<span className="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 text-xs rounded">
Primary
</span>
)}
{isSelected && !isPrimaryTechnician && (
<FaCheckCircle className="text-blue-500" size={16} />
)}
</label>
);
})}
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowTechnicianModal(false)}
className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg text-sm"
>
Cancel
</button>
<button
onClick={handleSaveTechnicians}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm flex items-center gap-2"
>
<FaCheckCircle size={14} />
{t('workOrders.detail.saveSelection')} ({selectedAdditionalTechnicians.length})
</button>
</div>
</div>
</div>
)}
{/* Technician Selection Confirmation Modal */}
{showTechnicianConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[60]">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<div className="flex items-start gap-3 mb-4">
<FaUser className="text-blue-500 text-xl mt-0.5" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Confirm Technician Assignment
</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1 text-sm">
Are you sure you want to assign this work order to the following technician(s)?
</p>
</div>
</div>
{/* Selected Technicians List */}
<div className="mb-4 max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
Selected Technicians ({pendingTechnicians.length}):
</p>
<div className="space-y-2">
{pendingTechnicians.map((tech) => {
const technicianInfo = availableTechnicians.find(t => t.name === tech);
const displayName = technicianInfo?.full_name || tech;
const email = tech;
return (
<div
key={tech}
className="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800"
>
<div className="w-8 h-8 rounded-full bg-blue-200 dark:bg-blue-800 flex items-center justify-center flex-shrink-0">
<FaUser className="text-blue-600 dark:text-blue-400" size={12} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{displayName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{email}
</p>
</div>
</div>
);
})}
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleCancelTechnicianConfirm}
className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg text-sm font-medium"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmTechnicians}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium flex items-center gap-2"
>
<FaCheckCircle size={14} />
{t('workOrders.detail.confirmAssignment')}
</button>
</div>
</div>
</div>
)}
{/* ============== FEEDBACK MODAL ============== */}
{showFeedbackModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto">
{/* Modal Header */}
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="text-lg font-bold text-gray-800 dark:text-white">
{feedbackMode === 'give' ? `${t('workOrders.detail.giveFeedback')}` : feedbackMode === 'edit' ? `✏️ ${t('workOrders.detail.editFeedback')}` : `${t('workOrders.detail.feedbackDetails')}`}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('workOrders.detail.workOrder')} <span className="font-medium text-gray-700 dark:text-gray-300">{workOrderName}</span>
</p>
</div>
<button
onClick={() => setShowFeedbackModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<FaTimes size={16} />
</button>
</div>
{/* Feedback By */}
<div className="mb-5 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('workOrders.detail.feedbackBy')}</p>
<p className="text-sm font-medium text-gray-800 dark:text-white">
{currentUserFullName || currentUser}
</p>
</div>
{/* Rating Parameters */}
<div className="space-y-5 mb-6">
{feedbackRatings.map((item, index) => (
<div key={item.parameter} className="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg border border-gray-200 dark:border-gray-600">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">
{item.parameter}
</label>
{/* Star Rating */}
<div className="flex items-center gap-1 mb-1">
{[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 (
<button
key={star}
type="button"
disabled={isDisabled}
onClick={() => {
if (!isDisabled) handleRatingChange(index, starValue);
}}
className={`text-2xl transition-all duration-150 ${
isDisabled ? 'cursor-default' : 'cursor-pointer hover:scale-110'
} ${isFilled ? 'text-yellow-400' : isHalf ? 'text-yellow-300' : 'text-gray-300 dark:text-gray-500'}`}
title={`${star} star${star > 1 ? 's' : ''}`}
>
</button>
);
})}
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400 font-medium">
{item.rating > 0 ? `${(item.rating * 5).toFixed(1)} / 5` : t('workOrders.detail.notRated')}
</span>
</div>
</div>
))}
</div>
{/* Overall Rating Display */}
{(feedbackMode === 'view' || feedbackMode === 'edit') && feedbackData?.overall > 0 && (
<div className="mb-5 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-center">
<p className="text-xs text-blue-600 dark:text-blue-400 mb-1 font-medium">{t('workOrders.detail.overallSatisfaction')}</p>
<div className="flex items-center justify-center gap-2">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-xl ${
feedbackData.overall >= star * 0.2 ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-500'
}`}
>
</span>
))}
</div>
<span className="text-lg font-bold text-blue-700 dark:text-blue-300">
{(feedbackData.overall * 5).toFixed(1)} / 5
</span>
</div>
</div>
)}
{/* Live Overall Preview for give/edit mode */}
{(feedbackMode === 'give' || feedbackMode === 'edit') && (
(() => {
const liveOverall = calculateOverall(feedbackRatings);
if (liveOverall <= 0) return null;
return (
<div className="mb-5 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-center">
<p className="text-xs text-green-600 dark:text-green-400 mb-1 font-medium">{t('workOrders.detail.overallRatingPreview')}</p>
<div className="flex items-center justify-center gap-2">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-xl ${
liveOverall >= star * 0.2 ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-500'
}`}
>
</span>
))}
</div>
<span className="text-lg font-bold text-green-700 dark:text-green-300">
{(liveOverall * 5).toFixed(1)} / 5
</span>
</div>
</div>
);
})()
)}
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
{feedbackMode === 'view' ? (
<>
<button
onClick={() => setShowFeedbackModal(false)}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium"
>
{t('listPages.close')}
</button>
{isWorkOrderOwner && (
<button
onClick={handleEditFeedback}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium flex items-center gap-2"
>
<FaEdit size={12} />
{t('workOrders.detail.editRating')}
</button>
)}
</>
) : (
<>
<button
onClick={() => {
if (feedbackMode === 'edit') {
setFeedbackMode('view');
} else {
setShowFeedbackModal(false);
}
}}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium"
disabled={feedbackSubmitting}
>
{t('common.cancel')}
</button>
<button
onClick={feedbackMode === 'edit' ? handleUpdateFeedback : handleSubmitFeedback}
disabled={feedbackSubmitting}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50"
>
{feedbackSubmitting ? (
<>
<FaSpinner className="animate-spin" size={12} />
{feedbackMode === 'edit' ? t('workOrders.detail.updating') : t('workOrders.detail.submitting')}
</>
) : (
<>
<FaCheckCircle size={12} />
{feedbackMode === 'edit' ? t('workOrders.detail.updateFeedback') : t('workOrders.detail.submitFeedback')}
</>
)}
</button>
</>
)}
</div>
</div>
</div>
)}
{/* ============== END FEEDBACK MODAL ============== */}
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
>
<FaArrowLeft />
<span className="text-gray-900 dark:text-white">{getPageTitle()}</span>
</button>
{/* Workflow State Badge */}
{/* {!isNewWorkOrder && (
<span className={`px-3 py-1 rounded-full text-xs font-medium ${stateStyle.bg} ${stateStyle.text} ${stateStyle.border} border`}>
{currentWorkflowState}
</span>
)} */}
{/* Repair Status Badge in Header */}
{!isNewWorkOrder && (
<span className={`px-3 py-1 rounded-full text-xs font-medium ${stateStyle.bg} ${stateStyle.text} ${stateStyle.border} border`}>
{workOrder?.repair_status || formData.repair_status || 'Open'}
</span>
)}
{/* ✅ Show Cancelled badge */}
{/* {!isNewWorkOrder && isCancelled && (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800">
{t('workOrders.detail.cancelled')}
</span>
)} */}
{isCreatingFromAsset && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-xs font-medium">
<FaLink size={10} />
{t('workOrders.detail.linkedFromAsset')} {assetIdFromParams}
</span>
)}
{isLoadingAsset && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium">
<FaSpinner className="animate-spin" size={12} />
{t('workOrders.detail.loadingAssetDetails')}
</span>
)}
</div>
<div className="flex items-center gap-3">
{/* Service Report Print Button */}
{!isNewWorkOrder && (
<button
onClick={handlePrintServiceReport}
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-lg flex items-center gap-2"
title={t('workOrders.detail.printServiceReport')}
>
<FaPrint />
{t('workOrders.detail.serviceReport')}
</button>
)}
{!isNewWorkOrder &&
workOrderName &&
currentWorkflowState === 'Repair InProgress' && (
<button
type="button"
onClick={() => navigate(`/work-orders/${encodeURIComponent(workOrderName)}/troubleshoot`)}
title={
troubleshootGuideCompleted
? t('workOrders.detail.troubleshootingGuideDoneTitle')
: undefined
}
className={
troubleshootGuideCompleted
? 'bg-gray-500 hover:bg-gray-600 text-white px-6 py-2 rounded-lg flex items-center gap-2 shadow-sm ring-1 ring-gray-400/50 dark:ring-gray-500/50'
: 'bg-violet-600 hover:bg-violet-700 text-white px-6 py-2 rounded-lg flex items-center gap-2'
}
>
{troubleshootGuideCompleted ? (
<FaCheckCircle className="shrink-0" aria-hidden />
) : (
<FaClipboardList className="shrink-0" aria-hidden />
)}
<span className="flex flex-col items-start leading-tight text-left">
<span>{t('workOrders.detail.troubleshootingTree')}</span>
{troubleshootGuideCompleted ? (
<span className="text-[10px] font-medium opacity-90">
{t('workOrders.detail.troubleshootingGuideDone')}
</span>
) : null}
</span>
</button>
)}
{/* ✅ 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 && (
<button
onClick={() => {
setIsEditing(true);
toast.info(t('workOrders.detail.editModeEnabled'), {
position: "top-right",
autoClose: 2000,
icon: <FaEdit />
});
}}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2"
>
<FaEdit />
{t('common.edit')}
</button>
)}
{/* ✅ 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 && (
<button
onClick={() => setShowCancelConfirm(true)}
className="bg-orange-600 hover:bg-orange-700 text-white px-6 py-2 rounded-lg flex items-center gap-2"
>
<FaBan />
{t('common.cancel')}
</button>
)}
{/* ✅ 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) && (
<DeleteRequestButton
doctype="Work_Order"
docname={workOrderName}
currentDeleteStatus={(workOrder?.custom_delete_status ?? null) as DeleteStatus}
userRoles={userRoles}
isSystemManager={isSystemManager}
inline
redirectOnDelete="/work-orders"
onStatusChange={(newStatus) => {
refetch();
}}
/>
)} */}
{isEditing && (
<>
<button
onClick={() => {
if (isNewWorkOrder) {
navigate(-1);
} else {
setIsEditing(false);
toast.info(t('workOrders.detail.editCancelledChangesDiscarded'), {
position: "top-right",
autoClose: 2000,
icon: <FaTimesCircle />
});
}
}}
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-2 rounded-lg"
disabled={saving}
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={saving || isLoadingAsset}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? t('common.saving') : t('common.saveChanges')}
</button>
</>
)}
</div>
</div>
{/* Asset Link Info Banner */}
{isCreatingFromAsset && isNewWorkOrder && (
<div className="mb-6 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<FaLink className="text-orange-500 mt-0.5" />
<div>
<h3 className="text-sm font-semibold text-orange-800 dark:text-orange-300">
{t('workOrders.createFromAsset')}
</h3>
<p className="text-xs text-orange-700 dark:text-orange-400 mt-1">
{t('workOrders.detail.assetInfoPrefilled')} <strong>{formData.asset_name || assetIdFromParams}</strong>.
{t('assets.pleaseSelectWorkOrderType')}
</p>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6" style={{ overflow: 'visible' }}>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6" style={{ overflow: 'visible' }}>
{/* Left Column - Main Info */}
<div className="lg:col-span-3 space-y-6" style={{ overflow: 'visible' }}>
{/* Asset Information Section */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white">
Asset Information
</h2>
{isCreatingFromAsset && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded text-[10px] font-medium">
<FaLink size={8} />
{t('workOrders.detail.fromAsset')}
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* First Row: Hospital, Asset Type, Technical Department */}
<div>
<LinkField
label={t('workOrders.detail.hospital')}
doctype="Company"
value={formData.company || ''}
onChange={(val) => setFormData({ ...formData, company: val, department: '' })}
disabled={!isEditing}
filters={{ domain: 'Healthcare' }}
/>
</div>
{/* <div>
<LinkField
label="Asset Type"
doctype="Asset Type"
value={formData.asset_type || ''}
onChange={handleAssetTypeChange}
disabled={!isEditing}
/>
</div> */}
<div className="relative z-[50]">
<LinkField
label={t('workOrders.detail.technicalDepartment')}
doctype="Issue Type"
value={formData.work_order_type || ''}
onChange={handleWorkOrderTypeChange}
disabled={!isEditing}
/>
{filteredManagers.length > 0 && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
{t('workOrders.detail.supervisorsAvailableForType', { count: filteredManagers.length })}
</p>
)}
</div>
{/* ✅ ADD THIS BLOCK - Civil Work Category - Only show when Technical Department is Civil works */}
{formData.work_order_type === 'Civil works-الأعمال المدنية' && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.civilWorkCategory')} <span className="text-red-500">*</span>
</label>
<select
name="custom_civil_work_category"
value={formData.custom_civil_work_category || ''}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">{t('workOrders.detail.selectCivilWorkCategory')}</option>
<option value="CARPENTARY">CARPENTARY</option>
<option value="MASONRY">MASONRY</option>
{/* <option value="MEDICAL GAS">MEDICAL GAS</option> */}
<option value="PAINTING">PAINTING</option>
<option value="PLUMPING">PLUMPING</option>
</select>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.woType')}
</label>
<select
name="custom_type"
value={formData.custom_type}
onChange={handlePriorityChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="Corrective">Corrective</option>
{/* <option value="Preventive">Preventive</option> */}
</select>
</div>
{/* <div className="relative z-[40]">
<LinkField
label={t('workOrders.detail.assetId')}
doctype="Asset"
value={formData.asset || ''}
onChange={handleAssetIdChange}
disabled={!isEditing || isLoadingAsset}
filters={formData.company ? { company: formData.company, docstatus: 1 } : { docstatus: 1 }}
/>
{formData.asset && (
<p className="mt-1 text-xs text-green-600 dark:text-green-400">
✓ Asset details auto-populated
</p>
)}
</div> */}
<div className="relative z-[40]">
{(() => {
// Build dynamic filters based on filled location fields
const assetFilters: Record<string, any> = { 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 (
<>
<LinkField
label={t('workOrders.detail.assetId')}
doctype="Asset"
value={formData.asset || ''}
onChange={handleAssetIdChange}
disabled={!isEditing || isLoadingAsset || !!formData.asset}
filters={assetFilters}
/>
{formData.asset && (
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-green-600 dark:text-green-400">
Asset details auto-populated
</p>
{isEditing && (
<button
type="button"
onClick={() => handleAssetIdChange('')}
className="text-xs text-red-500 hover:text-red-700 underline"
>
Clear Asset
</button>
)}
</div>
)}
{assetIsMandatory && !formData.asset && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<FaExclamationTriangle size={10} />
Asset is required based on your location selection
</p>
)}
{!formData.asset && (formData.custom_building || formData.department || formData.custom_room_no) && !assetIsMandatory && assetFilterCount > 0 && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
{assetFilterCount} asset(s) available for selected location
</p>
)}
</>
);
})()}
</div>
{formData.asset && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.assetName')}
</label>
<input
type="text"
name="asset_name"
value={formData.asset_name}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
)}
{/* Extension No - Link to Extension Directory doctype */}
<div>
<LinkField
label={t('workOrders.detail.extensionNo')}
doctype="Extension Directory"
value={formData.custom_extension_no || ''}
onChange={(val) => setFormData({ ...formData, custom_extension_no: val })}
disabled={!isEditing}
allowQuickCreate={true}
/>
</div>
{/* Building - Always visible */}
<div>
<LinkField
label={t('workOrders.detail.building')}
doctype="Building"
value={formData.custom_building || ''}
onChange={handleBuildingChange}
disabled={!isEditing}
allowQuickCreate={true}
/>
{!formData.custom_building && isEditing && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
{t('workOrders.detail.selectBuildingFirst')}
</p>
)}
</div>
{/* Department Name - Visible only if Building is filled OR Department already has value */}
{shouldShowDepartmentField() && (
<div>
<LinkField
label={t('workOrders.detail.departmentName')}
doctype="Department"
value={formData.department || ''}
onChange={handleDepartmentChangeForLocation}
disabled={!isEditing}
filters={
filteredDepartmentsForBuilding.length > 0
? { name: ['in', filteredDepartmentsForBuilding] }
: departmentFilters
}
allowQuickCreate={true}
/>
{formData.custom_building && filteredDepartmentsForBuilding.length === 0 && !loadingLocationData && (
<p className="mt-1 text-xs text-orange-500 dark:text-orange-400">
No departments mapped to this building
</p>
)}
{formData.custom_building && filteredDepartmentsForBuilding.length > 0 && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
{filteredDepartmentsForBuilding.length} department(s) available for this building
</p>
)}
{!formData.department && formData.custom_building && isEditing && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
Select Department to enable Room No
</p>
)}
</div>
)}
{/* Room No - Visible only if Department is filled OR Room No already has value */}
{shouldShowRoomNoField() && (
<div>
<LinkField
label={t('workOrders.detail.roomNo')}
doctype="Room"
value={formData.custom_room_no || ''}
onChange={handleRoomNoChangeWithLocation}
disabled={!isEditing}
query="asset_lite.api.room_filter.get_filtered_rooms"
filters={
(() => {
const roomFilters: Record<string, string> = {};
if (formData.custom_building) {
roomFilters['building'] = formData.custom_building;
}
if (formData.department) {
roomFilters['department'] = formData.department;
}
return roomFilters;
})()
}
allowQuickCreate={true}
/>
{loadingLocationData && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400 flex items-center gap-1">
<FaSpinner className="animate-spin" size={10} />
Loading...
</p>
)}
{!loadingLocationData && (formData.custom_building || formData.department) && filteredRoomNosForDepartment.length === 0 && (
<p className="mt-1 text-xs text-orange-500 dark:text-orange-400">
No rooms mapped to {formData.custom_building && formData.department
? 'this building + department'
: formData.custom_building
? 'this building'
: 'this department'}
</p>
)}
{!loadingLocationData && (formData.custom_building || formData.department) && filteredRoomNosForDepartment.length > 0 && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
{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)'}
</p>
)}
{!formData.custom_room_no && formData.department && isEditing && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
Select Room No to enable Location
</p>
)}
</div>
)}
{/* Location - Visible only if Room No is filled OR Location already has value */}
{shouldShowLocationField() && (
<div>
<LinkField
label={t('workOrders.detail.location')}
doctype="Location"
value={formData.custom_location || ''}
onChange={(val) => setFormData({ ...formData, custom_location: val })}
disabled={!isEditing}
allowQuickCreate={true}
/>
{loadingLocationData && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400 flex items-center gap-1">
<FaSpinner className="animate-spin" size={10} />
Loading location data...
</p>
)}
{(formData.custom_building || formData.department) && formData.custom_room_no && formData.custom_location && (
<p className="mt-1 text-xs text-green-600 dark:text-green-400">
Location auto-set from Infrastructure mapping
</p>
)}
</div>
)}
{/* Inspection - Only show if there's a value (read-only with link) */}
{formData.inspection && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Inspection
</label>
<div className="flex items-center gap-3 p-2 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<span className="text-gray-900 dark:text-white font-medium">
{formData.inspection}
</span>
<button
type="button"
onClick={() => navigate(`/inspections/${formData.inspection}`)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center gap-1 text-sm"
>
View <FaExternalLinkAlt size={12} />
</button>
</div>
</div>
)}
{/* Attachment Field - Moved to Asset Information section */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Opening Attachment
</label>
{/* Show existing attachment */}
{formData.custom_attachment ? (
<div className="flex items-center gap-3 p-2 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<FaFile className="text-blue-500 flex-shrink-0" size={16} />
<div className="flex-1 min-w-0">
<a
href={`${API_CONFIG.BASE_URL || ''}${formData.custom_attachment}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline truncate block"
>
{getFilenameFromUrl(formData.custom_attachment)}
</a>
</div>
{isEditing && (
<button
type="button"
onClick={handleRemoveAttachment}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors flex-shrink-0"
title={t('workOrders.detail.removeAttachment')}
>
<FaTimes size={12} />
</button>
)}
</div>
) : (
/* Upload button when no attachment */
isEditing ? (
<label className="flex items-center gap-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors border border-blue-200 dark:border-blue-800 w-fit">
{isUploading ? (
<>
<FaSpinner className="animate-spin" size={14} />
<span className="text-sm">{t('workOrders.detail.uploading')}</span>
</>
) : (
<>
<FaUpload size={14} />
<span className="text-sm">{t('workOrders.detail.uploadOpeningFile')}</span>
</>
)}
<input
type="file"
onChange={handleFileUpload}
disabled={isUploading}
className="hidden"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
/>
</label>
) : (
<p className="text-sm text-gray-400 dark:text-gray-500 italic">{t('workOrders.detail.noAttachment')}</p>
)
)}
</div>
{/* Closing Attachment Field */}
{['Pending Approval', 'Approved', 'Rejected', 'Closed'].includes(currentWorkflowState) && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.closingAttachment')}
</label>
{/* Show existing closing attachment */}
{formData.custom_attachment_on_close ? (
<div className="flex items-center gap-3 p-2 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<FaFile className="text-green-500 flex-shrink-0" size={16} />
<div className="flex-1 min-w-0">
<a
href={`${API_CONFIG.BASE_URL || ''}${formData.custom_attachment_on_close}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline truncate block"
>
{getFilenameFromUrl(formData.custom_attachment_on_close)}
</a>
</div>
{isEditing && (
<button
type="button"
onClick={() => {
setFormData(prev => ({
...prev,
custom_attachment_on_close: ''
}));
toast.info(t('workOrders.detail.closingAttachmentRemoved'), {
position: "top-right",
autoClose: 2000
});
}}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors flex-shrink-0"
title={t('workOrders.detail.removeClosingAttachment')}
>
<FaTimes size={12} />
</button>
)}
</div>
) : (
/* Upload button when no closing attachment */
isEditing ? (
<label className="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg cursor-pointer hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors border border-green-200 dark:border-green-800 w-fit">
{isUploading ? (
<>
<FaSpinner className="animate-spin" size={14} />
<span className="text-sm">{t('workOrders.detail.uploading')}</span>
</>
) : (
<>
<FaUpload size={14} />
<span className="text-sm">{t('workOrders.detail.uploadClosingFile')}</span>
</>
)}
<input
type="file"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Check file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
toast.error(t('workOrders.detail.fileSizeExceeds'), {
position: "top-right",
autoClose: 4000,
icon: <FaTimesCircle />
});
return;
}
setIsUploading(true);
try {
const formDataUpload = new FormData();
formDataUpload.append('file', file);
formDataUpload.append('is_private', '0');
formDataUpload.append('folder', 'Home/Attachments');
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_on_close: result.message.file_url
}));
toast.success(t('workOrders.detail.closingAttachmentUploadedSuccessfully'), {
position: "top-right",
autoClose: 3000,
icon: <FaCheckCircle />
});
} else {
throw new Error('Upload failed');
}
} catch (err) {
console.error('File upload error:', err);
toast.error(t('workOrders.detail.failedToUploadClosingAttachment'), {
position: "top-right",
autoClose: 4000,
icon: <FaTimesCircle />
});
} finally {
setIsUploading(false);
e.target.value = '';
}
}}
disabled={isUploading}
className="hidden"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
/>
</label>
) : (
<p className="text-sm text-gray-400 dark:text-gray-500 italic">{t('workOrders.detail.noClosingAttachment')}</p>
)
)}
</div>
)}
{/* Other Asset fields - Hidden for Non Biomedical */}
{!isNonBiomedical && (
<>
<div className="relative z-[40]">
<LinkField
label={t('workOrders.detail.assetId')}
doctype="Asset"
value={formData.asset || ''}
onChange={handleAssetIdChange}
disabled={!isEditing || isLoadingAsset}
filters={formData.company ? { company: formData.company } : {}}
/>
{formData.asset && (
<p className="mt-1 text-xs text-green-600 dark:text-green-400">
Asset details auto-populated
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Serial Number
</label>
<div className="flex gap-2">
<input
type="text"
name="serial_number"
value={formData.serial_number}
onChange={handleChange}
onBlur={handleSerialNumberBlur}
disabled={!isEditing || isLoadingAsset}
placeholder={t('workOrders.detail.enterSerialNumber')}
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{isEditing && (
<button
type="button"
onClick={handleSerialNumberSearch}
disabled={isLoadingAsset || !formData.serial_number}
className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title={t('workOrders.detail.searchAssetBySerial')}
>
<FaSearch size={14} />
</button>
)}
</div>
</div>
{formData.asset && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.assetName')}
</label>
<input
type="text"
name="asset_name"
value={formData.asset_name}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
)}
<div>
<LinkField
label={t('workOrders.detail.manufacturer')}
doctype="Manufacturer"
value={formData.manufacturer || ''}
onChange={(val) => setFormData({ ...formData, manufacturer: val })}
disabled={!isEditing}
/>
</div>
<div>
<LinkField
label={t('workOrders.detail.supplier')}
doctype="Supplier"
value={formData.supplier || ''}
onChange={(val) => setFormData({ ...formData, supplier: val })}
disabled={!isEditing}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.model')}
</label>
<input
type="text"
name="model"
value={formData.model}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</>
)}
</div>
</div>
{/* Work Order Information */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">{t('workOrders.detail.workOrderInformation')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.workOrderId')}
</label>
<input
type="text"
value={isNewWorkOrder || isDuplicating ? t('workOrders.detail.autoGenerated') : workOrder?.name}
disabled
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.priority')}
</label>
<select
name="custom_priority_"
value={formData.custom_priority_}
onChange={handlePriorityChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="Normal">Normal</option>
<option value="Medium">Medium</option>
<option value="Urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('commonFields.status')}
</label>
<input
type="text"
value={formData.repair_status}
disabled
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.pendingReason')}
</label>
<select
name="custom_pending_reason"
value={formData.custom_pending_reason || ''}
onChange={(e) => {
const value = e.target.value;
setFormData(prev => ({
...prev,
custom_pending_reason: value,
// Clear custom_reason if not "Other"
custom_reason: value === 'Other' ? prev.custom_reason : ''
}));
}}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">{t('workOrders.detail.selectPendingReason')}</option>
<option value="Need Part">Need Part</option>
<option value="Waiting For Quotation">Waiting For Quotation</option>
<option value="Waiting For PO">Waiting For PO</option>
<option value="Waiting For Part Delivery">Waiting For Part Delivery</option>
<option value="Other">Other</option>
</select>
</div>
{/* Other Reason - Only show when Pending Reason is "Other" */}
{formData.custom_pending_reason === 'Other' && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.otherReason')} <span className="text-red-500">*</span>
</label>
<input
type="text"
name="custom_reason"
value={formData.custom_reason || ''}
onChange={handleChange}
disabled={!isEditing}
placeholder={t('workOrders.detail.pleaseSpecifyReason')}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
)}
</div>
</div>
{/* Service Agreement Section - Hidden for Non Biomedical */}
{!isNonBiomedical && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
{t('workOrders.detail.serviceAgreementDetails')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.siteContractor')}
</label>
<input
type="text"
name="custom_site_contractor"
value={formData.custom_site_contractor}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.subcontractor')}
</label>
<input
type="text"
name="custom_subcontractor"
value={formData.custom_subcontractor}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Service Agreement
</label>
<select
name="custom_service_agreement"
value={formData.custom_service_agreement || ''}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">{t('workOrders.detail.selectServiceAgreement')}</option>
<option value="Warranty">Warranty</option>
<option value="Contract">Contract</option>
<option value="Frame Work">Frame Work</option>
<option value="Main Contractor">Main Contractor</option>
<option value="Out of warranty">Out of warranty</option>
<option value="Under Dismantle">Under Dismantle</option>
<option value="Under Installation">Under Installation</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Service Coverage
</label>
<select
name="custom_service_coverage"
value={formData.custom_service_coverage || ''}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">{t('workOrders.detail.selectServiceCoverage')}</option>
<option value="PM Only">PM Only</option>
<option value="Labour">Labour</option>
<option value="Labour & Parts">Labour & Parts</option>
<option value="Comprehensive">Comprehensive</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('filters.startDate')}
</label>
<input
type="date"
name="custom_start_date"
value={formData.custom_start_date || ''}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('filters.endDate')}
</label>
<input
type="date"
name="custom_end_date"
value={formData.custom_end_date || ''}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('assets.detail.totalAmount')}
</label>
<input
type="number"
name="custom_total_amount"
min="0"
step="0.01"
value={formData.custom_total_amount || 0}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
)}
{/* Description Section */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
{t('workOrders.detail.description')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<LinkField
label={t('workOrders.detail.supportIssue')}
doctype="Issue"
value={formData.issue || ''}
onChange={(val) => setFormData((prev) => ({ ...prev, issue: val }))}
disabled={!isEditing}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.natureOfComplaint')}
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
disabled={
!isEditing ||
(
!isNewWorkOrder &&
!(currentWorkflowState === 'Draft' && currentUser === workOrder?.owner)
)
}
placeholder={t('workOrders.detail.describeComplaint')}
rows={4}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.technicalReport')}
</label>
<textarea
name="actions_performed"
value={formData.actions_performed}
onChange={handleChange}
disabled={!isEditing}
placeholder={t('workOrders.detail.describeWorkPerformed')}
rows={4}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none"
/>
</div>
</div>
</div>
{/* Location & Assignment */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">{t('workOrders.detail.assignments')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Assigned Supervisor (renamed from Team Leader) */}
<div>
<LinkField
label={t('workOrders.detail.assignedSupervisor')}
doctype="User"
value={formData.custom_assigned_supervisor || ''}
onChange={handleSupervisorChange}
disabled={!isEditing}
filters={filteredManagers.length > 0 ? { name: ['in', filteredManagers] } : {}}
/>
{!formData.work_order_type && isEditing && (
<p className="mt-1 text-xs text-orange-500 dark:text-orange-400">
{t('workOrders.detail.selectWoTypeFirstForSupervisors')}
</p>
)}
{formData.work_order_type && filteredManagers.length === 0 && isEditing && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('workOrders.detail.noSupervisorsFound')}
</p>
)}
{!isNewWorkOrder && hasWorkControlRole && (
<p className="mt-1 text-xs text-red-500">
{t('workOrders.detail.requiredForExistingWO')}
</p>
)}
</div>
<div>
<LinkField
label={t('workOrders.detail.assignedEngineer')}
doctype="User"
value={formData.custom_assigned_engineer || ''}
onChange={(val) => setFormData({ ...formData, custom_assigned_engineer: val })}
disabled={!isEditing}
filters={filteredEngineers.length > 0 ? { name: ['in', filteredEngineers] } : {}}
/>
{!formData.work_order_type && isEditing && (
<p className="mt-1 text-xs text-orange-500 dark:text-orange-400">
{t('workOrders.detail.selectWoTypeFirstForEngineers')}
</p>
)}
{formData.work_order_type && filteredEngineers.length === 0 && isEditing && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('workOrders.detail.noEngineersFound')}
</p>
)}
{filteredEngineers.length > 0 && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
{t('workOrders.detail.engineersAvailable', { count: filteredEngineers.length })}
</p>
)}
{/* Show mandatory indicator for Work Control role */}
{!isNewWorkOrder && hasWorkControlRole && (
<p className="mt-1 text-xs text-red-500">
{t('workOrders.detail.requiredForExistingWO')}
</p>
)}
</div>
{/* Assigned Technician with Add More Button */}
{/* <div className="md:col-span-2"> */}
{/* <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> */}
{/* Primary Technician */}
<div>
<LinkField
label={t('workOrders.detail.assignedTechnician')}
doctype="User"
value={formData.assigned_technician || ''}
onChange={(val) => setFormData({ ...formData, assigned_technician: val })}
disabled={!isEditing}
filters={filteredTechnicians.length > 0 ? { name: ['in', filteredTechnicians] } : {}}
/>
{!formData.work_order_type && isEditing && (
<p className="mt-1 text-xs text-orange-500 dark:text-orange-400">
{t('workOrders.detail.selectWoTypeFirstForTechnicians')}
</p>
)}
{formData.work_order_type && filteredTechnicians.length === 0 && isEditing && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('workOrders.detail.noTechniciansFoundForType')}
</p>
)}
{filteredTechnicians.length > 0 && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
{t('workOrders.detail.techniciansAvailable', { count: filteredTechnicians.length })}
</p>
)}
{/* Show mandatory only for "Sent to Engineer" state */}
{!isNewWorkOrder && hasWorkControlRole && currentWorkflowState === 'Sent to Engineer' && (
<p className="mt-1 text-xs text-red-500">
{t('workOrders.detail.requiredForSentToEngineer')}
</p>
)}
</div>
{/* Additional Technicians */}
{/* <div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Additional Technicians
{formData.custom_add_technicians && (
<span className="ml-2 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded text-[10px] font-medium">
{formData.custom_add_technicians.split(',').filter(t => t.trim()).length}
</span>
)}
</label>
<div className="min-h-[38px] px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700">
{formData.custom_add_technicians ? (
<div className="flex flex-wrap gap-1.5">
{formData.custom_add_technicians.split(',').map((tech) => {
const trimmedTech = tech.trim();
if (!trimmedTech) return null;
const displayName = trimmedTech.includes('@')
? trimmedTech.split('@')[0]
: trimmedTech;
return (
<span
key={trimmedTech}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs"
title={trimmedTech}
>
{displayName}
{isEditing && (
<button
type="button"
onClick={() => handleRemoveTechnician(trimmedTech)}
className="text-blue-400 hover:text-red-500 transition-colors"
>
<FaTimes size={8} />
</button>
)}
</span>
);
})}
</div>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500">
{isEditing ? 'Click + to add technicians' : 'No additional technicians'}
</span>
)}
</div>
{isEditing && (
<button
type="button"
onClick={handleOpenTechnicianModal}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
>
<FaPlus size={10} />
Add Technicians
</button>
)}
</div> */}
{/* </div> */}
{/* </div> */}
{/* <div>
<LinkField
label={t('workOrders.detail.assignedTechnician')}
doctype="User"
value={formData.assigned_technician || ''}
onChange={(val) => setFormData({ ...formData, assigned_technician: val })}
disabled={!isEditing}
filters={filteredTechnicians.length > 0 ? { name: ['in', filteredTechnicians] } : {}}
/>
{!formData.work_order_type && isEditing && (
<p className="mt-1 text-xs text-orange-500 dark:text-orange-400">
Select Work Order Type first to filter Technicians
</p>
)}
{formData.work_order_type && filteredTechnicians.length === 0 && isEditing && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
No Technicians found for this Work Order Type
</p>
)}
{filteredTechnicians.length > 0 && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
{filteredTechnicians.length} Technician(s) available
</p>
)}
{!isNewWorkOrder && hasWorkControlRole && (
<p className="mt-1 text-xs text-red-500">
{t('workOrders.detail.requiredForExistingWO')}
</p>
)}
</div> */}
{/* ✅ Failure Date - DateTime Field */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Failure Date & Time
</label>
<div className="flex flex-wrap gap-2 items-center">
<input
type="datetime-local"
name="failure_date"
value={formData.failure_date}
onChange={handleFailureDateChange}
disabled={!isEditing}
className="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{isEditing && (
<button
type="button"
onClick={handleSetFailureDateNow}
className="flex-shrink-0 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-xs flex items-center gap-1"
title="Set to current date & time"
>
<FaClock size={12} />
Now
</button>
)}
</div>
{formData.failure_date && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatDateTimeForDisplay(formatDateTimeForFrappe(formData.failure_date))}
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Deadline Date
<span className="ml-1 text-xs text-gray-400">{t('workOrders.detail.autoCalculated')}</span>
</label>
<input
type="date"
name="custom_deadline_date"
value={formData.custom_deadline_date}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{isEditing && formData.failure_date && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{getDeadlineDaysText()}
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Total Hours Spent
</label>
<input
type="number"
name="total_hours_spent"
min="0"
step="0.5"
value={formData.total_hours_spent || 0}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
{/* ✅ First Responded On - DateTime Field */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
First Responded On
<span className="ml-1 text-xs text-gray-400">{t('workOrders.detail.autoSetOnRepairStart')}</span>
</label>
<div className="flex flex-wrap gap-2 items-center">
<input
type="datetime-local"
name="first_responded_on"
value={formData.first_responded_on}
onChange={handleFirstRespondedOnChange}
disabled={!isEditing}
className="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{isEditing && (
<button
type="button"
onClick={handleSetFirstRespondedOnNow}
className="flex-shrink-0 px-3 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md text-xs flex items-center gap-1"
title="Set to current date & time"
>
<FaClock size={12} />
Now
</button>
)}
</div>
{formData.first_responded_on && (
<p className="mt-1 text-xs text-green-600 dark:text-green-400">
{formatDateTimeForDisplay(formatDateTimeForFrappe(formData.first_responded_on))}
</p>
)}
</div>
{/* ✅ Completion Date - DateTime Field */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Completion Date & Time
<span className="ml-1 text-xs text-gray-400">{t('workOrders.detail.autoSetOnClose')}</span>
</label>
<div className="flex flex-wrap gap-2 items-center">
<input
type="datetime-local"
name="completion_date"
value={formData.completion_date}
onChange={handleCompletionDateChange}
disabled={!isEditing}
className="flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{isEditing && (
<button
type="button"
onClick={handleSetCompletionDateNow}
className="flex-shrink-0 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-md text-xs flex items-center gap-1"
title="Set to current date & time"
>
<FaClock size={12} />
Now
</button>
)}
</div>
{formData.completion_date && (
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
{formatDateTimeForDisplay(formatDateTimeForFrappe(formData.completion_date))}
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Total Travel Hours
</label>
<input
type="text"
name="custom_travel_hour"
value={formData.custom_travel_hour}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
{/* Stock Consumption Details Section */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700 relative z-20" style={{ overflow: 'visible' }}>
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
Stock Consumption Details
</h2>
{/* Stock Consumed Checkbox */}
<div className="mb-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.stock_consumption === 1}
onChange={(e) => {
setFormData({
...formData,
stock_consumption: e.target.checked ? 1 : 0,
stock_items: e.target.checked ? (formData.stock_items?.length ? formData.stock_items : [{
item_code: '',
warehouse: DEFAULT_WAREHOUSE,
consumed_quantity: 1,
valuation_rate: 0,
custom_available_stock: 0,
total_value: 0
}]) : []
});
}}
disabled={!isEditing}
className="w-5 h-5 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Parts Consumed
</span>
</label>
<p className="mt-1 ml-8 text-xs text-gray-500 dark:text-gray-400">
Check this if spare parts or items were used during the repair
</p>
</div>
{/* Stock Items Table - Only shown when checkbox is checked */}
{formData.stock_consumption === 1 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Stock Items
</h3>
{isEditing && (
<button
type="button"
onClick={() => {
setFormData({
...formData,
stock_items: [
...(formData.stock_items || []),
{
item_code: '',
warehouse: DEFAULT_WAREHOUSE,
consumed_quantity: 1,
valuation_rate: 0,
custom_available_stock: 0,
total_value: 0
}
]
});
toast.info(t('workOrders.detail.newStockItemRowAdded'), {
position: "top-right",
autoClose: 2000,
icon: <FaPlus />
});
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded-md transition-colors"
>
<FaPlus size={10} />
{t('workOrders.detail.addItem')}
</button>
)}
</div>
{/* Table */}
<div className="stock-items-table-wrapper">
<div className="stock-items-scroll-container">
<table className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-10">
#
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider min-w-[180px]">
{t('workOrders.detail.item')} <span className="text-red-500">*</span>
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-28">
{t('workOrders.detail.valuationRate')}
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider min-w-[180px]">
{t('workOrders.detail.warehouse')} <span className="text-red-500">*</span>
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">
{t('workOrders.detail.consumedQty')}
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-28">
{t('workOrders.detail.availableStock')}
</th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-28">
{t('workOrders.detail.totalValue')}
</th>
{isEditing && (
<th className="px-3 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
{t('workOrders.detail.action')}
</th>
)}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{(formData.stock_items || []).length === 0 ? (
<tr>
<td colSpan={isEditing ? 8 : 7} className="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<p>{t('workOrders.detail.noItemsAddedYet')}</p>
{isEditing && (
<button
type="button"
onClick={() => {
setFormData({
...formData,
stock_items: [{
item_code: '',
warehouse: DEFAULT_WAREHOUSE,
consumed_quantity: 1,
valuation_rate: 0,
custom_available_stock: 0,
total_value: 0
}]
});
}}
className="mt-2 text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
{t('workOrders.detail.addFirstItem')}
</button>
)}
</td>
</tr>
) : (
(formData.stock_items || []).map((item, index) => (
<React.Fragment key={index}>
<tr className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${stockWarnings[index] ? 'bg-red-50 dark:bg-red-900/20' : ''}`}>
<td className="px-3 py-3 text-gray-500 dark:text-gray-400">
{index + 1}
</td>
<td className="px-3 py-3 relative" style={{ zIndex: 50 - index }}>
<div className="relative">
<LinkField
label=""
doctype="Item"
value={item.item_code}
onChange={(val) => handleItemCodeChange(index, val)}
disabled={!isEditing}
placeholder={t('workOrders.detail.selectItem')}
compact={true}
usePortal={true}
filters={{
is_stock_item: 1,
...(formData.company ? { custom_hospital_name: formData.company } : {}),
...(formData.work_order_type ? { custom_technical_department: formData.work_order_type } : {})
}}
/>
</div>
</td>
<td className="px-3 py-3">
<input
type="number"
min="0"
step="0.01"
value={item.valuation_rate || 0}
onChange={(e) => {
const updatedItems = [...(formData.stock_items || [])];
const rate = parseFloat(e.target.value) || 0;
const qty = updatedItems[index].consumed_quantity || 0;
updatedItems[index] = {
...updatedItems[index],
valuation_rate: rate,
total_value: rate * qty
};
setFormData({ ...formData, stock_items: updatedItems });
}}
disabled={!isEditing}
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</td>
<td className="px-3 py-3 relative" style={{ zIndex: 50 - index }}>
<div className="relative">
<LinkField
label=""
doctype="Warehouse"
value={item.warehouse}
onChange={(val) => handleWarehouseChange(index, val)}
disabled={!isEditing}
placeholder={t('workOrders.detail.selectWarehouse')}
compact={true}
usePortal={true}
/>
</div>
</td>
<td className="px-3 py-3">
<input
type="number"
min="1"
value={item.consumed_quantity || 1}
onChange={(e) => handleConsumedQtyChange(index, parseInt(e.target.value) || 1)}
disabled={!isEditing}
className={`w-full px-2 py-1.5 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white ${stockWarnings[index] ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'}`}
/>
</td>
<td className="px-3 py-3">
<input
type="number"
min="0"
value={item.custom_available_stock || 0}
disabled
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
title={t('workOrders.detail.autoPopulatedFromStock')}
/>
</td>
<td className="px-3 py-3">
<input
type="number"
min="0"
step="0.01"
value={item.total_value || 0}
disabled
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
/>
</td>
{isEditing && (
<td className="px-3 py-3 text-center">
<button
type="button"
onClick={() => {
const deletedItem = formData.stock_items?.[index];
const updatedItems = (formData.stock_items || []).filter((_, i) => i !== index);
setFormData({ ...formData, stock_items: updatedItems });
// Clear warning for deleted row
setStockWarnings(prev => {
const newWarnings = { ...prev };
delete newWarnings[index];
return newWarnings;
});
toast.warning(t('workOrders.detail.stockItemRemoved', { item: deletedItem?.item_code || 'row' }), {
position: "top-right",
autoClose: 2000,
icon: <FaTrash />
});
}}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title={t('workOrders.detail.removeItem')}
>
<FaTrash size={14} />
</button>
</td>
)}
</tr>
{/* Stock Warning Row */}
{stockWarnings[index] && (
<tr className="bg-red-50 dark:bg-red-900/30">
<td colSpan={isEditing ? 8 : 7} className="px-4 py-2">
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-xs">
<FaExclamationTriangle />
<span>{stockWarnings[index]}</span>
</div>
</td>
</tr>
)}
</React.Fragment>
))
)}
</tbody>
</table>
</div>
</div>
{/* Summary */}
{(formData.stock_items || []).length > 0 && (
<div className="mt-3 flex flex-wrap justify-between items-center gap-4 text-sm bg-gray-50 dark:bg-gray-700/50 p-3 rounded-lg">
<span className="text-gray-500 dark:text-gray-400">
{t('workOrders.detail.totalItems')} <span className="font-medium text-gray-700 dark:text-gray-300">{(formData.stock_items || []).length}</span>
</span>
<span className="text-gray-500 dark:text-gray-400">
Total Qty: <span className="font-medium text-gray-700 dark:text-gray-300">
{(formData.stock_items || []).reduce((sum, item) => sum + (Number(item.consumed_quantity) || 0), 0)}
</span>
</span>
<span className="text-gray-500 dark:text-gray-400">
Total Value: <span className="font-semibold text-green-600 dark:text-green-400">
{(formData.stock_items || []).reduce((sum, item) => sum + (Number(item.total_value) || 0), 0).toFixed(2)}
</span>
</span>
</div>
)}
</div>
)}
{/* Total Repair Cost field - below stock items table */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Total Repair Cost
</label>
<input
type="number"
name="total_repair_cost"
min="0"
step="0.01"
value={formData.total_repair_cost || 0}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
</div>
</div>
{/* ✅ ADD THIS — Comments Section */}
{!isNewWorkOrder && (
<CommentSection
referenceDoctype="Work_Order"
referenceName={workOrderName || null}
title="Comments & Discussion" // optional, default shown
pollInterval={30000} // optional, auto-refresh every 30s (0 = off)
initialLimit={5} // optional, comments shown before "show more"
collapsible={true} // optional, allow collapse/expand
startCollapsed={false} // optional, start collapsed
/>
)}
</div>
{/* Right Column - Status & Workflow */}
<div className="space-y-6">
{/* Workflow Actions Card */}
{!isNewWorkOrder && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white">
Workflow Actions
</h2>
</div>
{/* Current State */}
<div className={`p-4 rounded-lg border mb-4 ${stateStyle.bg} ${stateStyle.border}`}>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('workOrders.detail.currentState')}</p>
<p className={`text-lg font-semibold ${stateStyle.text}`}>
{currentWorkflowState}
</p>
</div>
{/* Workflow Loading */}
{workflowLoading && (
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 mb-4">
<FaSpinner className="animate-spin" />
<span className="text-sm">{t('workOrders.detail.loadingActions')}</span>
</div>
)}
{/* Workflow Error */}
{workflowError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg mb-4">
<div className="flex items-start gap-2">
<FaExclamationTriangle className="text-red-500 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{workflowError}</p>
</div>
</div>
)}
{/* Confirmation Dialog */}
{confirmAction && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg mb-4">
<div className="flex items-start gap-2 mb-3">
<FaExclamationTriangle className="text-yellow-500 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{t('workOrders.detail.confirmAction')}
</p>
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
{t('workOrders.detail.confirmActionMessage', { action: confirmAction.action })}
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleWorkflowAction(confirmAction.action, confirmAction.nextState)}
disabled={actionLoading}
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded-md disabled:opacity-50"
>
{actionLoading ? (
<span className="flex items-center gap-1">
<FaSpinner className="animate-spin" size={12} />
{t('workOrders.detail.processing')}
</span>
) : (
t('workOrders.detail.yesAction', { action: confirmAction.action })
)}
</button>
<button
onClick={() => setConfirmAction(null)}
disabled={actionLoading}
className="px-3 py-1.5 bg-gray-300 hover:bg-gray-400 text-gray-700 text-sm rounded-md disabled:opacity-50"
>
{t('common.cancel')}
</button>
</div>
</div>
)}
{/* Available Actions - Filter based on editing state */}
{!workflowLoading && transitions.length > 0 && !confirmAction && (
<div className="space-y-3">
{isSystemManager && (
<div className="p-2 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg mb-2">
<p className="text-xs text-purple-700 dark:text-purple-300">
{t('workOrders.detail.systemManagerNote')}
</p>
</div>
)}
{/* Show message when editing - other actions hidden until save */}
{isEditing && transitions.some(t => t.action !== 'Apply') && (
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-2">
<p className="text-xs text-blue-700 dark:text-blue-300">
<FaInfoCircle className="inline mr-1" size={10} />
{t('workOrders.detail.saveToSeeActions')}
</p>
</div>
)}
{/* Filter transitions: When editing, only show "Apply". After save, show all. */}
{(() => {
const visibleTransitions = isEditing
? transitions.filter(t => t.action === 'Apply')
: transitions;
if (visibleTransitions.length === 0) return null;
return (
<>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
<FaInfoCircle size={12} />
{t('workOrders.detail.availableActions')} ({visibleTransitions.length})
</p>
<div className="flex flex-col gap-2">
{visibleTransitions.map((transition: WorkflowTransition, index: number) => (
<button
key={`${transition.action}-${transition.next_state}-${index}`}
onClick={() => handleWorkflowAction(transition.action, transition.next_state)}
disabled={actionLoading}
className={`w-full px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2 ${getButtonStyle(transition.action)}`}
>
{actionLoading ? (
<FaSpinner className="animate-spin" size={14} />
) : (
<span>{getIcon(transition.action)}</span>
)}
{transition.action}
</button>
))}
</div>
</>
);
})()}
{/* Show next states - only for visible transitions */}
{(() => {
const visibleTransitions = isEditing
? transitions.filter(t => t.action === 'Apply')
: transitions;
if (visibleTransitions.length === 0) return null;
return (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">{t('workOrders.detail.actionResults')}</p>
{visibleTransitions.map((t: WorkflowTransition, i: number) => (
<p key={i} className="text-xs text-gray-600 dark:text-gray-300">
{t.action} <span className="font-medium">{t.next_state}</span>
</p>
))}
</div>
);
})()}
</div>
)}
{/* Special message for "Sent to Engineer" state - Choose Your Path */}
{!isNewWorkOrder && !workflowLoading && currentWorkflowState === 'Sent to Engineer' && (
(() => {
const hasTechnician = !!(formData.assigned_technician || workOrder?.assigned_technician);
const hasTechnicalReport = !!(formData.actions_performed || workOrder?.actions_performed || '').trim();
// Case 1: Both filled - show success message
if (hasTechnician && hasTechnicalReport) {
return (
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-4">
<div className="flex items-start gap-3">
<FaCheckCircle className="text-green-500 mt-0.5 flex-shrink-0" size={18} />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-green-800 dark:text-green-200 mb-1">
{t('workOrders.detail.allOptionsAvailable')}
</p>
<p className="text-xs text-green-600 dark:text-green-400">
{t('workOrders.detail.bothFilledMessage')}
</p>
</div>
</div>
</div>
);
}
// Case 2: Only technician assigned
if (hasTechnician && !hasTechnicalReport) {
return (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<div className="flex items-start gap-3">
<FaInfoCircle className="text-blue-500 mt-0.5 flex-shrink-0" size={18} />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
{t('workOrders.detail.technicianAssigned')}
</p>
{/* Assigned status */}
<div className="mb-3 p-2 bg-green-100 dark:bg-green-900/30 rounded border border-green-200 dark:border-green-700">
<p className="text-xs text-green-700 dark:text-green-300 flex items-start gap-1">
<FaCheckCircle size={10} className="mt-0.5 flex-shrink-0" />
<span className="min-w-0">
{t('workOrders.detail.assignedTechnicianLabel')}{' '}
<strong className="break-all">{formData.assigned_technician || workOrder?.assigned_technician}</strong>
</span>
</p>
</div>
{/* Additional option hint */}
<div className="p-2 bg-white dark:bg-gray-800 rounded border border-blue-100 dark:border-blue-700">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('workOrders.detail.wantToEnableSendToSupervisor')}
</p>
<p className="text-xs text-orange-600 dark:text-orange-400 flex items-start gap-1">
<FaExclamationTriangle size={10} className="mt-0.5 flex-shrink-0" />
<span>{t('workOrders.detail.fillTechnicalReportToUnlock')}</span>
</p>
</div>
</div>
</div>
</div>
);
}
// Case 3: Only technical report filled
if (!hasTechnician && hasTechnicalReport) {
return (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<div className="flex items-start gap-3">
<FaInfoCircle className="text-blue-500 mt-0.5 flex-shrink-0" size={18} />
<div className="flex-1">
<p className="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
{t('workOrders.detail.technicalReportFilled')}
</p>
{/* Technical Report status */}
<div className="mb-3 p-2 bg-green-100 dark:bg-green-900/30 rounded border border-green-200 dark:border-green-700">
<p className="text-xs text-green-700 dark:text-green-300 flex items-center gap-1">
<FaCheckCircle size={10} />
"Send to Supervisor" button is now available above
</p>
</div>
{/* Additional option hint */}
<div className="p-2 bg-white dark:bg-gray-800 rounded border border-blue-100 dark:border-blue-700">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Want more workflow options?
</p>
<p className="text-xs text-orange-600 dark:text-orange-400 flex items-center gap-1">
<FaExclamationTriangle size={10} />
Assign a "Technician" to enable technician workflow actions
</p>
</div>
</div>
</div>
</div>
);
}
// Case 4: Neither filled - show original "Choose Your Path"
return (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<div className="flex items-start gap-3">
<FaInfoCircle className="text-blue-500 mt-0.5 flex-shrink-0" size={18} />
<div className="flex-1">
<p className="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
Choose Your Path
</p>
{/* Option 1: Assign Technician */}
<div className="mb-3 p-2 bg-white dark:bg-gray-800 rounded border border-blue-100 dark:border-blue-700">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Option 1: Assign to Technician
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Edit the form Fill "Assigned Technician" Save Proceed with technician workflow
</p>
</div>
{/* Option 2: Send to Supervisor */}
<div className="p-2 bg-white dark:bg-gray-800 rounded border border-blue-100 dark:border-blue-700">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Option 2: Send Directly to Supervisor
</p>
<p className="text-xs text-orange-600 dark:text-orange-400 flex items-center gap-1">
<FaExclamationTriangle size={10} />
Fill "Technical Report" field to enable "Send to Supervisor" button
</p>
</div>
</div>
</div>
</div>
);
})()
)}
{/* ✅ ADD THIS - Special message for "Repair InProgress" state - Technical Report Required */}
{!isNewWorkOrder && !workflowLoading && currentWorkflowState === 'Repair InProgress' &&
(userRoles.includes('Technician') || userRoles.includes('Contractor Engineer')) && (
(() => {
const hasTechnicalReport = !!(formData.actions_performed || workOrder?.actions_performed || '').trim();
if (hasTechnicalReport) {
return (
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-4">
<div className="flex items-start gap-3">
<FaCheckCircle className="text-green-500 mt-0.5 flex-shrink-0" size={18} />
<div className="flex-1">
<p className="text-sm font-semibold text-green-800 dark:text-green-200 mb-1">
Ready for Approval
</p>
<p className="text-xs text-green-600 dark:text-green-400">
Technical Report is filled. You can now use "Send For Approval" action.
</p>
</div>
</div>
</div>
);
}
// Technical Report is empty
return (
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg mb-4">
<div className="flex items-start gap-3">
<FaExclamationTriangle className="text-orange-500 mt-0.5 flex-shrink-0" size={18} />
<div className="flex-1">
<p className="text-sm font-semibold text-orange-800 dark:text-orange-200 mb-2">
Action Required
</p>
<div className="p-2 bg-white dark:bg-gray-800 rounded border border-orange-100 dark:border-orange-700">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Technical Report Required
</p>
<p className="text-xs text-orange-600 dark:text-orange-400 flex items-center gap-1">
<FaExclamationTriangle size={10} />
Please fill the "Technical Report" field to enable "Send For Approval" button
</p>
</div>
</div>
</div>
</div>
);
})()
)}
{/* Apply Hidden Message - Show for Work Control OR Sent to Engineer/Repair InProgress state */}
{!workflowLoading && applyHiddenReason && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<div className="flex items-start gap-2">
<FaInfoCircle className="text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
{applyHiddenReason.includes('WO Type has been changed')
? 'Technical Dept Changed'
: currentWorkflowState === 'Sent to Engineer'
? 'Choose Your Path'
: currentWorkflowState === 'Repair InProgress'
? 'Action Required'
: 'Apply Action Unavailable'}
</p>
<p className="text-xs text-orange-600 dark:text-orange-400 mt-1">
{applyHiddenReason}
</p>
{!applyHiddenReason.includes('Technical Dept has been changed') && (
<p className="text-xs text-orange-500 dark:text-orange-500 mt-2">
Edit the form to assign the required fields, then save to enable the action.
</p>
)}
</div>
</div>
</div>
)}
{/* No Actions Available */}
{!workflowLoading && transitions.length === 0 && (
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
No workflow actions available
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-1">
(Conditions may not be met for available transitions)
</p>
</div>
)}
</div>
)}
{/* {!isNewWorkOrder && ['Approved', 'Closed'].includes(workOrder?.workflow_state || formData.workflow_state || '') && ( */}
{/* Feedback Card */}
{!isNewWorkOrder && ['Approved', 'Closed'].includes(workOrder?.workflow_state || formData.workflow_state || '') && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
Feedback
</h2>
{feedbackLoading ? (
<div className="flex items-center justify-center py-4">
<FaSpinner className="animate-spin text-blue-500 mr-2" size={14} />
<span className="text-sm text-gray-500 dark:text-gray-400">{t('workOrders.detail.checkingFeedback')}</span>
</div>
) : feedbackData ? (
// Feedback exists - show rating summary to ALL users
<div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-4 text-center">
<p className="text-xs text-green-600 dark:text-green-400 mb-2 font-medium">
{isWorkOrderOwner ? 'Your Rating' : 'Owner Rating'}
</p>
<div className="flex items-center justify-center gap-1 mb-1">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-xl ${
(feedbackData.overall || 0) >= star * 0.2 ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-500'
}`}
>
</span>
))}
</div>
<p className="text-lg font-bold text-green-700 dark:text-green-300">
{((feedbackData.overall || 0) * 5).toFixed(1)} / 5
</p>
</div>
<button
onClick={handleOpenSeeRating}
className="w-full px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
>
See Rating
</button>
</div>
) : isWorkOrderOwner ? (
// No feedback yet - only OWNER can give feedback
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 text-center">
{t('workOrders.detail.howWasService')}
</p>
<button
onClick={handleOpenGiveFeedback}
className="w-full px-4 py-2.5 bg-yellow-500 hover:bg-yellow-600 text-white rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
>
{t('workOrders.detail.giveFeedback')}
</button>
</div>
) : (
// No feedback yet - non-owner sees info message
<div className="text-center py-4">
<p className="text-sm text-gray-400 dark:text-gray-500 italic">
{t('workOrders.detail.noFeedbackYet')}
</p>
</div>
)}
</div>
)}
{/* Status Summary Card */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">{t('workOrders.detail.statusSummary')}</h2>
{!isNewWorkOrder && workOrder && (
<div className="space-y-4">
{/* <div className="p-4 bg-blue-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('workOrders.detail.repairStatus')}</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{workOrder.repair_status || 'Open'}
</p>
</div> */}
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('workOrders.detail.priority')}</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{workOrder.custom_priority_ || 'Normal'}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('workOrders.detail.created')}</p>
<p className="text-sm text-gray-900 dark:text-white">
{workOrder.creation ? formatDateTimeForDisplay(workOrder.creation) : '-'}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('workOrders.detail.lastModified')}</p>
<p className="text-sm text-gray-900 dark:text-white">
{workOrder.modified ? formatDateTimeForDisplay(workOrder.modified) : '-'}
</p>
</div>
{/* ✅ DocStatus indicator */}
{/* <div className={`p-4 rounded-lg ${
isCancelled
? 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
: isSubmitted
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
}`}>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('workOrders.detail.documentStatus')}</p>
<p className={`text-lg font-semibold ${
isCancelled
? 'text-red-700 dark:text-red-300'
: isSubmitted
? 'text-green-700 dark:text-green-300'
: 'text-blue-700 dark:text-blue-300'
}`}>
{isCancelled ? 'Cancelled' : isSubmitted ? 'Submitted (Closed)' : 'Draft'}
</p>
</div> */}
</div>
)}
{isNewWorkOrder && (
<div className="text-center py-8">
<FaInfoCircle className="text-4xl text-gray-400 dark:text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('workOrders.detail.statusInfoAfterCreation')}
</p>
</div>
)}
</div>
{/* Audit Log / Activity Section */}
{!isNewWorkOrder && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div
className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"
>
<div
className="flex items-center gap-2 cursor-pointer flex-1"
onClick={() => setAuditLogsExpanded(!auditLogsExpanded)}
>
<FaHistory className="text-blue-500" size={16} />
<h2 className="text-base font-semibold text-gray-800 dark:text-white">
{t('workOrders.detail.activityLog')}
</h2>
{auditLogs.length > 0 && (
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-xs font-medium">
{auditLogs.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Refresh button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
fetchAuditLogs();
toast.info(t('workOrders.detail.activityLogRefreshed'), {
position: "top-right",
autoClose: 1500,
icon: <FaSync />
});
}}
disabled={auditLogsLoading}
className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors disabled:opacity-50"
title={t('workOrders.detail.refreshActivityLogTitle')}
>
<FaSync className={`${auditLogsLoading ? 'animate-spin' : ''}`} size={12} />
</button>
{/* Expand/Collapse button */}
<button
type="button"
onClick={() => setAuditLogsExpanded(!auditLogsExpanded)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{auditLogsExpanded ? <FaChevronUp size={14} /> : <FaChevronDown size={14} />}
</button>
</div>
</div>
{/* Content */}
{auditLogsExpanded && (
<div className="p-4">
{/* Loading State */}
{auditLogsLoading && (
<div className="flex items-center justify-center py-8">
<FaSpinner className="animate-spin text-blue-500 mr-2" size={16} />
<span className="text-sm text-gray-500 dark:text-gray-400">{t('workOrders.detail.loadingActivity')}</span>
</div>
)}
{/* Empty State - Still show Created entry */}
{!auditLogsLoading && auditLogs.length === 0 && (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
{/* No changes message */}
<div className="relative pl-8 mb-4">
<div className="absolute left-1.5 top-1 w-3 h-3 rounded-full border-2 border-white dark:border-gray-800 bg-gray-300 dark:bg-gray-600"></div>
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<p className="text-xs text-gray-500 dark:text-gray-400 italic">
{t('workOrders.detail.noChangesRecorded')}
</p>
</div>
</div>
{/* Created By Entry */}
{workOrder?.creation && workOrder?.owner && (
<div className="relative pl-8">
{/* Timeline dot - Green for creation */}
<div className="absolute left-1.5 top-1 w-3 h-3 rounded-full border-2 border-white dark:border-gray-800 bg-green-500"></div>
{/* Entry content */}
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800/50">
{/* Header: User and Time */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 flex items-center justify-center">
<FaUser className="text-green-600 dark:text-green-400" size={10} />
</div>
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
{formatUsername(workOrder.owner)}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<FaClock size={10} />
<span title={formatDateTimeForDisplay(workOrder.creation)}>
{formatAuditDate(workOrder.creation)}
</span>
</div>
</div>
{/* Created message */}
<div className="text-xs">
<span className="inline-flex items-center gap-1.5 px-2 py-1 bg-green-100 dark:bg-green-800/50 text-green-700 dark:text-green-300 rounded font-medium">
<FaCheckCircle size={10} />
Created this Work Order
</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Activity Timeline */}
{!auditLogsLoading && auditLogs.length > 0 && (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
{/* Activity entries */}
<div className="space-y-4">
{(showAllLogs ? auditLogs : auditLogs.slice(0, 5)).map((log, index) => (
<div key={log.name} className="relative pl-8">
{/* Timeline dot */}
<div className={`absolute left-1.5 top-1 w-3 h-3 rounded-full border-2 border-white dark:border-gray-800 ${
index === 0 ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
}`}></div>
{/* Entry content */}
<div className={`p-3 rounded-lg ${
index === 0
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800/50'
: 'bg-gray-50 dark:bg-gray-700/50'
}`}>
{/* Header: User and Time */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<FaUser className="text-gray-500 dark:text-gray-400" size={10} />
</div>
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
{formatUsername(log.owner)}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<FaClock size={10} />
<span title={formatDateTimeForDisplay(log.creation)}>
{formatAuditDate(log.creation)}
</span>
</div>
</div>
{/* Changes */}
<div className="space-y-1.5">
{log.changes.length > 0 ? (
log.changes.map((change, changeIndex) => (
<div key={changeIndex} className="text-xs">
<span className={`font-medium ${getChangeColor(change.field)}`}>
{formatFieldName(change.field)}
</span>
<span className="text-gray-500 dark:text-gray-400"> {t('workOrders.detail.changedFrom')} </span>
<span className="px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded text-[10px] font-mono">
{formatValue(change.oldValue)}
</span>
<span className="text-gray-500 dark:text-gray-400"> </span>
<span className="px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded text-[10px] font-mono">
{formatValue(change.newValue)}
</span>
</div>
))
) : (
<p className="text-xs text-gray-500 dark:text-gray-400 italic">
{t('workOrders.detail.documentUpdated')}
</p>
)}
{/* Show added items */}
{log.added && log.added.length > 0 && (
<div className="text-xs text-green-600 dark:text-green-400">
<span className="font-medium">{t('workOrders.detail.added')}</span> {log.added.length} {t('workOrders.detail.items')}
</div>
)}
{/* Show removed items */}
{log.removed && log.removed.length > 0 && (
<div className="text-xs text-red-600 dark:text-red-400">
<span className="font-medium">{t('workOrders.detail.removed')}</span> {log.removed.length} {t('workOrders.detail.items')}
</div>
)}
{/* Show row changes */}
{log.rowChanged && log.rowChanged.length > 0 && (
<div className="text-xs text-orange-600 dark:text-orange-400">
<span className="font-medium">{t('workOrders.detail.modified')}</span> {log.rowChanged.length} {t('workOrders.detail.rows')}
</div>
)}
</div>
</div>
</div>
))}
</div>
{/* Show More/Less Button */}
{auditLogs.length > 5 && (
<div className="mt-4 text-center">
<button
type="button"
onClick={() => setShowAllLogs(!showAllLogs)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors"
>
{showAllLogs ? (
<>
<FaChevronUp size={10} />
{t('workOrders.detail.showLess')}
</>
) : (
<>
<FaChevronDown size={10} />
{t('workOrders.detail.showAll')} ({auditLogs.length} {t('workOrders.detail.entries')})
</>
)}
</button>
</div>
)}
{/* Created By Entry - Always at bottom */}
{workOrder?.creation && workOrder?.owner && (
<div className="relative pl-8 mt-4">
{/* Timeline dot - Green for creation */}
<div className="absolute left-1.5 top-1 w-3 h-3 rounded-full border-2 border-white dark:border-gray-800 bg-green-500"></div>
{/* Entry content */}
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800/50">
{/* Header: User and Time */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-green-200 dark:bg-green-800 flex items-center justify-center">
<FaUser className="text-green-600 dark:text-green-400" size={10} />
</div>
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
{formatUsername(workOrder.owner)}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<FaClock size={10} />
<span title={new Date(workOrder.creation).toLocaleString()}>
{formatAuditDate(workOrder.creation)}
</span>
</div>
</div>
{/* Created message */}
<div className="text-xs">
<span className="inline-flex items-center gap-1.5 px-2 py-1 bg-green-100 dark:bg-green-800/50 text-green-700 dark:text-green-300 rounded font-medium">
<FaCheckCircle size={10} />
Created this Work Order
</span>
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
// )}
// </div>
// </div>
// </form>
)}
{/* Delete Request Button */}
{!isNewWorkOrder && (workOrder?.docstatus === 0 || workOrder?.docstatus === 2) && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
Delete Request
</h2>
<DeleteRequestButton
doctype="Work_Order"
docname={workOrderName}
currentDeleteStatus={(workOrder?.custom_delete_status ?? null) as DeleteStatus}
userRoles={userRoles}
isSystemManager={isSystemManager}
redirectOnDelete="/work-orders"
onStatusChange={(newStatus) => {
refetch();
}}
/>
</div>
)}
</div>
</div>
</form>
{/* Styles for table scrolling */}
<style>{`
/* Wrapper for table - allow natural flow */
.stock-items-table-wrapper {
width: 100%;
position: relative;
}
/* Scroll container handles horizontal scrolling only */
.stock-items-scroll-container {
width: 100%;
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
}
/* Table styling */
.stock-items-scroll-container table {
border-collapse: separate;
border-spacing: 0;
min-width: 100%;
}
.stock-items-scroll-container tbody tr td {
position: relative;
}
`}</style>
</div>
);
};
export default WorkOrderDetail;