825 lines
33 KiB
TypeScript
825 lines
33 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import ppmPlannerService, { type BulkScheduleData } from '../services/ppmPlannerService';
|
|
import { FaFilter, FaCalendar, FaCheckCircle, FaSearch, FaArrowLeft, FaSpinner } from 'react-icons/fa';
|
|
import LinkField from '../components/LinkField';
|
|
|
|
// Updated Asset interface to match backend API response
|
|
interface Asset {
|
|
name: string;
|
|
asset_name: string;
|
|
custom_modality?: string;
|
|
company?: string;
|
|
custom_manufacturer?: string;
|
|
custom_device_status?: string;
|
|
custom_model?: string;
|
|
}
|
|
|
|
// Updated filters to match backend API parameters
|
|
interface AssetFilters {
|
|
company?: string;
|
|
custom_modality?: string;
|
|
custom_manufacturer?: string;
|
|
custom_device_status?: string;
|
|
custom_model?: string;
|
|
department?: string;
|
|
}
|
|
|
|
const PPMPlanner: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
|
|
// Updated filters state to match backend API
|
|
const [filters, setFilters] = useState<AssetFilters>({});
|
|
const [selectedAssets, setSelectedAssets] = useState<string[]>([]);
|
|
const [scheduleData, setScheduleData] = useState({
|
|
start_date: '',
|
|
end_date: '',
|
|
maintenance_team: '',
|
|
assign_to: '',
|
|
pm_for: '',
|
|
maintenance_manager: '',
|
|
periodicity: 'Monthly',
|
|
maintenance_type: 'Preventive',
|
|
no_of_pms: '',
|
|
department: ''
|
|
});
|
|
const [loading, setLoading] = useState(false);
|
|
const [fetchingAssets, setFetchingAssets] = useState(false);
|
|
const [assets, setAssets] = useState<Asset[]>([]);
|
|
const [filterOptions, setFilterOptions] = useState({
|
|
modalities: [] as string[],
|
|
assetTypes: [] as string[],
|
|
departments: [] as string[],
|
|
locations: [] as string[],
|
|
manufacturers: [] as string[],
|
|
models: [] as string[],
|
|
company: [] as string[]
|
|
});
|
|
const [maintenanceTeams, setMaintenanceTeams] = useState<any[]>([]);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [successResult, setSuccessResult] = useState<{
|
|
show: boolean;
|
|
document?: string;
|
|
count: number;
|
|
type: 'pm_schedule' | 'maintenance_logs';
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadFilterOptions();
|
|
loadMaintenanceTeams();
|
|
}, []);
|
|
|
|
// Helper function to calculate end_date based on start_date, periodicity, and no_of_pms
|
|
const calculateEndDate = (startDate: string, periodicity: string, noOfPms: string): string | null => {
|
|
if (!startDate || !periodicity || !noOfPms) {
|
|
return null;
|
|
}
|
|
|
|
const noOfPmsNum = parseInt(noOfPms, 10);
|
|
if (isNaN(noOfPmsNum) || noOfPmsNum < 1) {
|
|
return null;
|
|
}
|
|
|
|
// Start date is PM #1, so we need to add (no_of_pms - 1) periods
|
|
const occurrences = noOfPmsNum - 1;
|
|
if (occurrences < 0) {
|
|
return null;
|
|
}
|
|
|
|
const start = new Date(startDate);
|
|
const end = new Date(start);
|
|
|
|
switch (periodicity) {
|
|
case 'Daily':
|
|
end.setDate(end.getDate() + occurrences);
|
|
break;
|
|
case 'Weekly':
|
|
end.setDate(end.getDate() + (occurrences * 7));
|
|
break;
|
|
case 'Monthly':
|
|
end.setMonth(end.getMonth() + occurrences);
|
|
break;
|
|
case 'Quarterly':
|
|
end.setMonth(end.getMonth() + (occurrences * 3));
|
|
break;
|
|
case 'Half-yearly':
|
|
end.setMonth(end.getMonth() + (occurrences * 6));
|
|
break;
|
|
case 'Yearly':
|
|
end.setFullYear(end.getFullYear() + occurrences);
|
|
break;
|
|
case '2 Yearly':
|
|
end.setFullYear(end.getFullYear() + (occurrences * 2));
|
|
break;
|
|
case '3 Yearly':
|
|
end.setFullYear(end.getFullYear() + (occurrences * 3));
|
|
break;
|
|
default:
|
|
return null;
|
|
}
|
|
|
|
// Format as YYYY-MM-DD
|
|
const year = end.getFullYear();
|
|
const month = String(end.getMonth() + 1).padStart(2, '0');
|
|
const day = String(end.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
};
|
|
|
|
// Auto-populate maintenance_manager and assign_to when maintenance_team is selected
|
|
useEffect(() => {
|
|
const fetchTeamDetails = async () => {
|
|
if (scheduleData.maintenance_team) {
|
|
const teamDetails = await ppmPlannerService.getMaintenanceTeamDetails(scheduleData.maintenance_team);
|
|
if (teamDetails) {
|
|
setScheduleData(prev => ({
|
|
...prev,
|
|
maintenance_manager: teamDetails.maintenance_manager || '',
|
|
assign_to: (teamDetails.team_members && teamDetails.team_members.length === 1)
|
|
? teamDetails.team_members[0]
|
|
: prev.assign_to
|
|
}));
|
|
}
|
|
} else {
|
|
setScheduleData(prev => ({
|
|
...prev,
|
|
maintenance_manager: '',
|
|
assign_to: ''
|
|
}));
|
|
}
|
|
};
|
|
|
|
fetchTeamDetails();
|
|
}, [scheduleData.maintenance_team]);
|
|
|
|
// Auto-calculate end_date when start_date, periodicity, or no_of_pms changes
|
|
useEffect(() => {
|
|
if (scheduleData.start_date && scheduleData.periodicity && scheduleData.no_of_pms) {
|
|
const calculatedEndDate = calculateEndDate(
|
|
scheduleData.start_date,
|
|
scheduleData.periodicity,
|
|
scheduleData.no_of_pms
|
|
);
|
|
if (calculatedEndDate) {
|
|
setScheduleData(prev => ({
|
|
...prev,
|
|
end_date: calculatedEndDate
|
|
}));
|
|
}
|
|
}
|
|
}, [scheduleData.start_date, scheduleData.periodicity, scheduleData.no_of_pms]);
|
|
|
|
const loadFilterOptions = async () => {
|
|
const options = await ppmPlannerService.getFilterOptions();
|
|
setFilterOptions(options);
|
|
};
|
|
|
|
const loadMaintenanceTeams = async () => {
|
|
const teams = await ppmPlannerService.getMaintenanceTeams();
|
|
setMaintenanceTeams(teams);
|
|
};
|
|
|
|
// Updated fetchAssets to call the Frappe Server Script API
|
|
const fetchAssets = async () => {
|
|
setFetchingAssets(true);
|
|
try {
|
|
// Build query parameters matching backend API
|
|
const params = new URLSearchParams();
|
|
|
|
if (filters.company) {
|
|
params.append('company', filters.company);
|
|
}
|
|
if (filters.custom_modality) {
|
|
params.append('custom_modality', filters.custom_modality);
|
|
}
|
|
if (filters.custom_manufacturer) {
|
|
params.append('custom_manufacturer', filters.custom_manufacturer);
|
|
}
|
|
if (filters.custom_device_status) {
|
|
params.append('custom_device_status', filters.custom_device_status);
|
|
}
|
|
if (filters.custom_model) {
|
|
params.append('custom_model', filters.custom_model);
|
|
}
|
|
if (filters.department) {
|
|
params.append('department', filters.department);
|
|
}
|
|
|
|
// Call the Frappe Server Script API
|
|
const response = await fetch(`/api/method/get_assets?${params.toString()}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
credentials: 'include', // Important for Frappe session authentication
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const filteredAssets: Asset[] = data.message || [];
|
|
|
|
setAssets(filteredAssets);
|
|
setSelectedAssets([]);
|
|
} catch (error) {
|
|
console.error('Error fetching assets:', error);
|
|
alert('Failed to fetch assets: ' + (error instanceof Error ? error.message : 'Unknown error'));
|
|
} finally {
|
|
setFetchingAssets(false);
|
|
}
|
|
};
|
|
|
|
const handleFilterChange = (key: keyof AssetFilters, value: string) => {
|
|
setFilters(prev => ({ ...prev, [key]: value || undefined }));
|
|
};
|
|
|
|
const toggleAssetSelection = (assetName: string) => {
|
|
setSelectedAssets(prev =>
|
|
prev.includes(assetName)
|
|
? prev.filter(name => name !== assetName)
|
|
: [...prev, assetName]
|
|
);
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
const filteredAssets = getFilteredAssets();
|
|
if (selectedAssets.length === filteredAssets.length && filteredAssets.length > 0) {
|
|
setSelectedAssets([]);
|
|
} else {
|
|
setSelectedAssets(filteredAssets.map(a => a.name));
|
|
}
|
|
};
|
|
|
|
const getFilteredAssets = () => {
|
|
if (!searchTerm) return assets;
|
|
const term = searchTerm.toLowerCase();
|
|
return assets.filter(asset =>
|
|
asset.asset_name?.toLowerCase().includes(term) ||
|
|
asset.custom_modality?.toLowerCase().includes(term) ||
|
|
asset.company?.toLowerCase().includes(term) ||
|
|
asset.custom_manufacturer?.toLowerCase().includes(term) ||
|
|
asset.custom_model?.toLowerCase().includes(term) ||
|
|
asset.custom_device_status?.toLowerCase().includes(term)
|
|
);
|
|
};
|
|
|
|
const handleGenerateSchedule = async () => {
|
|
if (selectedAssets.length === 0) {
|
|
alert('Please select at least one asset');
|
|
return;
|
|
}
|
|
if (!filters.company) {
|
|
alert('Please select a Hospital/Company in the filters first');
|
|
return;
|
|
}
|
|
if (!scheduleData.pm_for) {
|
|
alert('Please enter a PM Name');
|
|
return;
|
|
}
|
|
if (!scheduleData.start_date || !scheduleData.end_date) {
|
|
alert('Please select start and end dates');
|
|
return;
|
|
}
|
|
|
|
if (new Date(scheduleData.start_date) > new Date(scheduleData.end_date)) {
|
|
alert('Start date must be before end date');
|
|
return;
|
|
}
|
|
|
|
// Require assign_to to avoid validation error when Asset Maintenance is auto-created
|
|
if (!scheduleData.assign_to) {
|
|
alert('Please assign the task to a team member. This is required for Asset Maintenance creation.');
|
|
return;
|
|
}
|
|
|
|
const confirmed = window.confirm(
|
|
`Are you sure you want to create maintenance schedules for ${selectedAssets.length} asset(s)?`
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
// Get full asset details for selected assets (including manufacturer and model)
|
|
const selectedAssetDetails = assets
|
|
.filter(asset => selectedAssets.includes(asset.name))
|
|
.map(asset => ({
|
|
name: asset.name,
|
|
custom_manufacturer: asset.custom_manufacturer,
|
|
custom_model: asset.custom_model,
|
|
}));
|
|
|
|
const bulkData: BulkScheduleData = {
|
|
assets: selectedAssetDetails, // Pass full asset details
|
|
start_date: scheduleData.start_date,
|
|
end_date: scheduleData.end_date,
|
|
maintenance_team: scheduleData.maintenance_team || undefined,
|
|
assign_to: scheduleData.assign_to || undefined,
|
|
maintenance_manager: scheduleData.maintenance_manager || undefined,
|
|
periodicity: scheduleData.periodicity,
|
|
maintenance_type: scheduleData.maintenance_type,
|
|
no_of_pms: scheduleData.no_of_pms || undefined,
|
|
pm_for: scheduleData.pm_for || undefined,
|
|
hospital: filters.company!,
|
|
// Form-level fields from filters
|
|
modality: filters.custom_modality,
|
|
manufacturer: filters.custom_manufacturer,
|
|
model: filters.custom_model,
|
|
department: scheduleData.department || filters.department || undefined,
|
|
};
|
|
|
|
// Debug logs
|
|
console.log('=== DEBUG: Selected Asset Details ===', selectedAssetDetails);
|
|
console.log('=== DEBUG: bulkData ===', bulkData);
|
|
|
|
const result = await ppmPlannerService.createBulkMaintenanceSchedules(bulkData);
|
|
|
|
setSuccessResult({
|
|
show: true,
|
|
document: result.document,
|
|
count: result.created || selectedAssets.length,
|
|
type: 'pm_schedule'
|
|
});
|
|
|
|
setSelectedAssets([]);
|
|
setScheduleData({
|
|
start_date: '',
|
|
end_date: '',
|
|
maintenance_team: '',
|
|
assign_to: '',
|
|
pm_for: '',
|
|
maintenance_manager: '',
|
|
periodicity: 'Monthly',
|
|
maintenance_type: 'Preventive',
|
|
no_of_pms: '',
|
|
department: ''
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating schedules:', error);
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
alert(`Failed to create maintenance schedules:\n\n${errorMessage}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredAssets = getFilteredAssets();
|
|
const hasActiveFilters = Object.values(filters).some(v => v);
|
|
|
|
return (
|
|
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
|
{/* Header */}
|
|
<div className="mb-6 flex items-center gap-4">
|
|
<button
|
|
onClick={() => navigate('/ppm-planner')}
|
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
|
|
>
|
|
<FaArrowLeft />
|
|
<span>Back to PPM Planner</span>
|
|
</button>
|
|
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
|
|
PPM Planner - Bulk Schedule Generator
|
|
</h1>
|
|
</div>
|
|
|
|
{/* Filter Section - Updated to match backend API */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
|
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2 text-gray-800 dark:text-white">
|
|
<FaFilter /> Filter Assets
|
|
</h2>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
{/* Company/Hospital - Required */}
|
|
<div>
|
|
<LinkField
|
|
label="Hospital/Company *"
|
|
doctype="Company"
|
|
value={filters.company || ''}
|
|
onChange={(val) => handleFilterChange('company', val)}
|
|
placeholder="Select a hospital/company"
|
|
/>
|
|
</div>
|
|
|
|
{/* Modality */}
|
|
<div>
|
|
<LinkField
|
|
label="Modality"
|
|
doctype="Modality"
|
|
value={filters.custom_modality || ''}
|
|
onChange={(val) => handleFilterChange('custom_modality', val)}
|
|
placeholder="Leave empty for all modalities"
|
|
/>
|
|
</div>
|
|
|
|
{/* Manufacturer */}
|
|
<div>
|
|
<LinkField
|
|
label="Manufacturer"
|
|
doctype="Manufacturer"
|
|
value={filters.custom_manufacturer || ''}
|
|
onChange={(val) => handleFilterChange('custom_manufacturer', val)}
|
|
placeholder="Leave empty for all manufacturers"
|
|
/>
|
|
</div>
|
|
|
|
{/* Device Status */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">
|
|
Device Status
|
|
</label>
|
|
<select
|
|
value={filters.custom_device_status || ''}
|
|
onChange={(e) => handleFilterChange('custom_device_status', e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="Active">Active</option>
|
|
<option value="Inactive">Inactive</option>
|
|
<option value="Under Maintenance">Under Maintenance</option>
|
|
<option value="Decommissioned">Decommissioned</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Model */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">
|
|
Model
|
|
</label>
|
|
<select
|
|
value={filters.custom_model || ''}
|
|
onChange={(e) => handleFilterChange('custom_model', e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="">Select Model (optional)</option>
|
|
{filterOptions.models.map(mod => (
|
|
<option key={mod} value={mod}>{mod}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Department */}
|
|
<div>
|
|
<LinkField
|
|
label="Department"
|
|
doctype="Department"
|
|
value={filters.department || ''}
|
|
onChange={(val) => handleFilterChange('department', val)}
|
|
placeholder="Select department (optional)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex gap-3">
|
|
<button
|
|
onClick={fetchAssets}
|
|
disabled={fetchingAssets}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{fetchingAssets ? (
|
|
<>
|
|
<FaSpinner className="animate-spin" />
|
|
Loading...
|
|
</>
|
|
) : (
|
|
<>
|
|
<FaSearch />
|
|
Fetch Assets
|
|
</>
|
|
)}
|
|
</button>
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={() => {
|
|
setFilters({});
|
|
setAssets([]);
|
|
setSelectedAssets([]);
|
|
}}
|
|
className="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg"
|
|
>
|
|
Clear Filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Asset Selection Section - Updated table columns */}
|
|
{assets.length > 0 && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
|
|
Select Assets ({selectedAssets.length} of {assets.length} selected)
|
|
</h2>
|
|
<div className="flex gap-3 items-center">
|
|
<div className="relative">
|
|
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search assets..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleSelectAll}
|
|
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 px-4 py-2 border border-blue-600 dark:border-blue-400 rounded-lg"
|
|
>
|
|
{selectedAssets.length === filteredAssets.length && filteredAssets.length > 0 ? 'Deselect All' : 'Select All'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
|
|
<tr>
|
|
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedAssets.length === filteredAssets.length && filteredAssets.length > 0}
|
|
onChange={handleSelectAll}
|
|
className="rounded"
|
|
/>
|
|
</th>
|
|
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Asset Name</th>
|
|
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Modality</th>
|
|
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</th>
|
|
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Model</th>
|
|
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{filteredAssets.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="p-6 text-center text-gray-500 dark:text-gray-400">
|
|
No assets match your search criteria
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredAssets.map(asset => (
|
|
<tr
|
|
key={asset.name}
|
|
className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${
|
|
selectedAssets.includes(asset.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
|
}`}
|
|
>
|
|
<td className="p-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedAssets.includes(asset.name)}
|
|
onChange={() => toggleAssetSelection(asset.name)}
|
|
className="rounded"
|
|
/>
|
|
</td>
|
|
<td className="p-3 text-sm text-gray-900 dark:text-white font-medium">{asset.asset_name}</td>
|
|
<td className="p-3 text-sm text-gray-700 dark:text-gray-300">{asset.custom_modality || '-'}</td>
|
|
<td className="p-3 text-sm text-gray-700 dark:text-gray-300">{asset.custom_manufacturer || '-'}</td>
|
|
<td className="p-3 text-sm text-gray-700 dark:text-gray-300">{asset.custom_model || '-'}</td>
|
|
<td className="p-3 text-sm">
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
asset.custom_device_status === 'Active'
|
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
|
: asset.custom_device_status === 'Inactive'
|
|
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
|
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
|
}`}>
|
|
{asset.custom_device_status || '-'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Schedule Configuration */}
|
|
{selectedAssets.length > 0 && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
|
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2 text-gray-800 dark:text-white">
|
|
<FaCalendar /> Schedule Configuration
|
|
</h2>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">PPM Name *</label>
|
|
<input
|
|
type="text"
|
|
value={scheduleData.pm_for}
|
|
onChange={(e) => setScheduleData(prev => ({ ...prev, pm_for: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="Enter PM Name"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">First PPM Date *</label>
|
|
<input
|
|
type="date"
|
|
value={scheduleData.start_date}
|
|
onChange={(e) => setScheduleData(prev => ({ ...prev, start_date: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Periodicity *</label>
|
|
<select
|
|
value={scheduleData.periodicity}
|
|
onChange={(e) => setScheduleData(prev => ({ ...prev, periodicity: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="Daily">Daily</option>
|
|
<option value="Weekly">Weekly</option>
|
|
<option value="Monthly">Monthly</option>
|
|
<option value="Quarterly">Quarterly</option>
|
|
<option value="Half-yearly">Half-yearly</option>
|
|
<option value="Yearly">Yearly</option>
|
|
<option value="2 Yearly">2 Yearly</option>
|
|
<option value="3 Yearly">3 Yearly</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Maintenance Type</label>
|
|
<select
|
|
value={scheduleData.maintenance_type}
|
|
onChange={(e) => setScheduleData(prev => ({ ...prev, maintenance_type: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="Preventive">Preventive</option>
|
|
<option value="Corrective">Corrective</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">No. of PMs</label>
|
|
<input
|
|
type="number"
|
|
value={scheduleData.no_of_pms}
|
|
onChange={(e) => setScheduleData(prev => ({ ...prev, no_of_pms: e.target.value }))}
|
|
min="1"
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
placeholder="Enter number of PMs"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
End date will be auto-calculated
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Last PPM Date *</label>
|
|
<input
|
|
type="date"
|
|
value={scheduleData.end_date}
|
|
onChange={(e) => setScheduleData(prev => ({ ...prev, end_date: e.target.value }))}
|
|
min={scheduleData.start_date}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<LinkField
|
|
label="Maintenance Team"
|
|
doctype="Asset Maintenance Team"
|
|
value={scheduleData.maintenance_team}
|
|
onChange={(val) => setScheduleData(prev => ({ ...prev, maintenance_team: val }))}
|
|
/>
|
|
{scheduleData.maintenance_manager && (
|
|
<div className="mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-xs text-gray-600 dark:text-gray-400">
|
|
<span className="font-medium">Maintenance Manager:</span> {scheduleData.maintenance_manager}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<LinkField
|
|
label="Assign To *"
|
|
doctype="User"
|
|
value={scheduleData.assign_to}
|
|
onChange={(val) => setScheduleData(prev => ({ ...prev, assign_to: val }))}
|
|
placeholder={scheduleData.maintenance_team ? "Select user (auto-selected if only one team member)" : "Select user to assign tasks"}
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
Required for Asset Maintenance creation
|
|
</p>
|
|
{scheduleData.assign_to && (
|
|
<div className="mt-1 p-2 bg-green-50 dark:bg-green-900/20 rounded text-xs text-gray-600 dark:text-gray-400">
|
|
<span className="font-medium">Assigned To:</span> {scheduleData.assign_to}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<LinkField
|
|
label="Department"
|
|
doctype="Department"
|
|
value={scheduleData.department}
|
|
onChange={(val) => setScheduleData(prev => ({ ...prev, department: val }))}
|
|
placeholder="Select department (optional)"
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
<button
|
|
onClick={handleGenerateSchedule}
|
|
disabled={loading || !scheduleData.start_date || !scheduleData.end_date || !scheduleData.pm_for || !scheduleData.assign_to}
|
|
className="mt-6 bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<FaSpinner className="animate-spin" />
|
|
Creating Schedules...
|
|
</>
|
|
) : (
|
|
<>
|
|
<FaCheckCircle />
|
|
Generate Maintenance Schedules ({selectedAssets.length} asset{selectedAssets.length !== 1 ? 's' : ''})
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{assets.length === 0 && !fetchingAssets && !successResult?.show && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-12 text-center">
|
|
<FaFilter className="mx-auto text-4xl text-gray-400 dark:text-gray-600 mb-4" />
|
|
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
No Assets Loaded
|
|
</h3>
|
|
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
|
Use the filters above to search for assets, then click "Fetch Assets" to load them.
|
|
</p>
|
|
<p className="text-sm text-blue-600 dark:text-blue-400">
|
|
Note: Only submitted assets without existing maintenance schedules will be shown.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Success Result Modal */}
|
|
{successResult?.show && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-lg w-full p-6">
|
|
<div className="text-center mb-6">
|
|
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<FaCheckCircle className="text-green-600 dark:text-green-400 text-3xl" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
|
Schedules Created Successfully!
|
|
</h2>
|
|
<p className="text-gray-600 dark:text-gray-400">
|
|
{successResult.count} maintenance schedule{successResult.count !== 1 ? 's' : ''} have been created.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-6">
|
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
|
What was created:
|
|
</h3>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-600 dark:text-gray-400">Document:</span>
|
|
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">
|
|
{successResult.document}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
A PM Schedule Generator document has been created with {successResult.count} asset(s).
|
|
Frappe will automatically create Asset Maintenance Logs when the document is submitted.
|
|
You can view and manage it in the PPM Planner section.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
{successResult.document && (
|
|
<button
|
|
onClick={() => {
|
|
navigate(`/ppm-planner/${successResult.document}`);
|
|
setSuccessResult(null);
|
|
}}
|
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-3 rounded-lg font-medium text-center flex items-center justify-center gap-2"
|
|
>
|
|
<FaCalendar />
|
|
View PPM Planner
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => navigate('/maintenance-calendar')}
|
|
className="w-full bg-purple-600 hover:bg-purple-700 text-white px-4 py-3 rounded-lg font-medium flex items-center justify-center gap-2"
|
|
>
|
|
<FaCalendar />
|
|
View Calendar
|
|
</button>
|
|
<button
|
|
onClick={() => setSuccessResult(null)}
|
|
className="w-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 px-4 py-3 rounded-lg font-medium"
|
|
>
|
|
Create More Schedules
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PPMPlanner; |