6856 lines
298 KiB
TypeScript
6856 lines
298 KiB
TypeScript
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; |