latest changes of ppm

This commit is contained in:
Vipeesh 2026-01-13 14:53:14 +00:00
parent 829a9227d8
commit 5274a58bd1
29 changed files with 8225 additions and 2550 deletions

View File

@ -44,6 +44,10 @@ import ItemDetail from './pages/ItemDetail';
import ComingSoon from './pages/ComingSoon'; import ComingSoon from './pages/ComingSoon';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import Header from './components/Header'; import Header from './components/Header';
import IssueList from './pages/IssueList';
import IssueDetail from './pages/IssueDetail';
import MaintenanceTeamList from './pages/MaintenanceTeamList';
import MaintenanceTeamDetail from './pages/MaintenanceTeamDetail';
// Layout with Sidebar and Header // Layout with Sidebar and Header
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => { const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@ -201,10 +205,21 @@ const App: React.FC = () => {
</ProtectedRoute> </ProtectedRoute>
} /> } />
<Route path="/maintenance-team" element={ {/* <Route path="/maintenance-team" element={
<ProtectedRoute> <ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Maintenance Team" /></LayoutWithSidebar> <LayoutWithSidebar><ComingSoon title="Maintenance Team" /></LayoutWithSidebar>
</ProtectedRoute> </ProtectedRoute>
} /> */}
<Route path="/maintenance-teams" element={
<ProtectedRoute>
<LayoutWithSidebar><MaintenanceTeamList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance-teams/:teamName" element={
<ProtectedRoute>
<LayoutWithSidebar><MaintenanceTeamDetail /></LayoutWithSidebar>
</ProtectedRoute>
} /> } />
<Route path="/procurement" element={ <Route path="/procurement" element={
@ -219,12 +234,24 @@ const App: React.FC = () => {
</ProtectedRoute> </ProtectedRoute>
} /> } />
<Route path="/support" element={ {/* <Route path="/support" element={
<ProtectedRoute> <ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Support" /></LayoutWithSidebar> <LayoutWithSidebar><ComingSoon title="Support" /></LayoutWithSidebar>
</ProtectedRoute> </ProtectedRoute>
} /> */}
<Route path="/support" element={
<ProtectedRoute>
<LayoutWithSidebar><IssueList /></LayoutWithSidebar>
</ProtectedRoute>
} /> } />
<Route path="/support/:issueName" element={
<ProtectedRoute>
<LayoutWithSidebar><IssueDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
{/* Default redirect */} {/* Default redirect */}
<Route path="/" element={<Navigate to="/login" replace />} /> <Route path="/" element={<Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />

View File

@ -142,10 +142,10 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
visible: true visible: true
}, },
{ {
id: 'maintenance-team', id: 'maintenance-teams',
title: 'Maintenance Team', title: 'Maintenance Team',
icon: <Users size={20} />, icon: <Users size={20} />,
path: '/maintenance-team', path: '/maintenance-teams',
visible: true visible: true
}, },
{ {

View File

@ -0,0 +1,133 @@
import { useState, useEffect, useCallback } from 'react';
import issueService, { type Issue, type CreateIssueData, type IssueListParams } from '../services/issueService';
// Hook for fetching issue list
export const useIssueList = (params: IssueListParams = {}) => {
const [issues, setIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
const fetchIssues = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await issueService.getIssues(params);
setIssues(response.data);
// Get total count for pagination
const count = await issueService.getIssueCount(params.filters);
setTotalCount(count);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
} finally {
setLoading(false);
}
}, [JSON.stringify(params)]);
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
return {
issues,
loading,
error,
totalCount,
refetch: fetchIssues,
};
};
// Hook for fetching single issue details
export const useIssueDetails = (issueName: string | null) => {
const [issue, setIssue] = useState<Issue | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchIssue = useCallback(async () => {
if (!issueName) {
setIssue(null);
return;
}
try {
setLoading(true);
setError(null);
const data = await issueService.getIssue(issueName);
setIssue(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch issue details');
} finally {
setLoading(false);
}
}, [issueName]);
useEffect(() => {
fetchIssue();
}, [fetchIssue]);
return {
issue,
loading,
error,
refetch: fetchIssue,
};
};
// Hook for issue mutations (create, update, delete)
export const useIssueMutations = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createIssue = async (data: CreateIssueData): Promise<Issue> => {
try {
setLoading(true);
setError(null);
const result = await issueService.createIssue(data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create issue';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateIssue = async (name: string, data: Partial<CreateIssueData>): Promise<Issue> => {
try {
setLoading(true);
setError(null);
const result = await issueService.updateIssue(name, data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update issue';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteIssue = async (name: string): Promise<void> => {
try {
setLoading(true);
setError(null);
await issueService.deleteIssue(name);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete issue';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return {
createIssue,
updateIssue,
deleteIssue,
loading,
error,
};
};

View File

@ -0,0 +1,142 @@
import { useState, useEffect, useCallback } from 'react';
import maintenanceTeamService, {
type MaintenanceTeam,
type CreateMaintenanceTeamData,
type MaintenanceTeamListParams
} from '../services/maintenanceTeamService';
// Hook for fetching maintenance team list
export const useMaintenanceTeamList = (params: MaintenanceTeamListParams = {}) => {
const [teams, setTeams] = useState<MaintenanceTeam[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
const fetchTeams = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await maintenanceTeamService.getMaintenanceTeams(params);
setTeams(response.data);
// Get total count for pagination
const count = await maintenanceTeamService.getMaintenanceTeamCount(params.filters);
setTotalCount(count);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance teams');
} finally {
setLoading(false);
}
}, [JSON.stringify(params)]);
useEffect(() => {
fetchTeams();
}, [fetchTeams]);
return {
teams,
loading,
error,
totalCount,
refetch: fetchTeams,
};
};
// Hook for fetching single maintenance team details
export const useMaintenanceTeamDetails = (teamName: string | null) => {
const [team, setTeam] = useState<MaintenanceTeam | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchTeam = useCallback(async () => {
if (!teamName) {
setTeam(null);
return;
}
try {
setLoading(true);
setError(null);
const data = await maintenanceTeamService.getMaintenanceTeam(teamName);
setTeam(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance team details');
} finally {
setLoading(false);
}
}, [teamName]);
useEffect(() => {
fetchTeam();
}, [fetchTeam]);
return {
team,
loading,
error,
refetch: fetchTeam,
};
};
// Hook for maintenance team mutations (create, update, delete)
export const useMaintenanceTeamMutations = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createTeam = async (data: CreateMaintenanceTeamData): Promise<MaintenanceTeam> => {
try {
setLoading(true);
setError(null);
const result = await maintenanceTeamService.createMaintenanceTeam(data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create maintenance team';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateTeam = async (name: string, data: Partial<CreateMaintenanceTeamData>): Promise<MaintenanceTeam> => {
try {
setLoading(true);
setError(null);
const result = await maintenanceTeamService.updateMaintenanceTeam(name, data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update maintenance team';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteTeam = async (name: string): Promise<void> => {
try {
setLoading(true);
setError(null);
await maintenanceTeamService.deleteMaintenanceTeam(name);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete maintenance team';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const getUserFullName = async (email: string): Promise<string> => {
return await maintenanceTeamService.getUserFullName(email);
};
return {
createTeam,
updateTeam,
deleteTeam,
getUserFullName,
loading,
error,
};
};

View File

@ -13,6 +13,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save", "save": "Save",
"delete": "Delete", "delete": "Delete",
"deleting": "Deleting...",
"edit": "Edit", "edit": "Edit",
"create": "Create", "create": "Create",
"search": "Search", "search": "Search",
@ -27,12 +28,21 @@
"lightMode": "Light Mode", "lightMode": "Light Mode",
"language": "Language", "language": "Language",
"english": "English", "english": "English",
"arabic": "Arabic" "arabic": "Arabic",
"backToDashboard": "Back to Dashboard"
}, },
"sidebar": { "sidebar": {
"title": "Seera-ASM", "title": "Seera-ASM",
"loggedInAs": "Logged in as:", "loggedInAs": "Logged in as:",
"version": "Seera-ASM v1.0" "version": "Seera-ASM v1.0",
"inventory": "Inventory",
"ppmPlanner": "PPM Planner",
"maintenanceCalendar": "Maintenance Calendar",
"activeMap": "Active Map",
"maintenanceTeam": "Maintenance Team",
"procurement": "Procurement",
"sla": "Service Level Agreement (SLA)",
"support": "Support"
}, },
"login": { "login": {
"title": "Seera-ASM", "title": "Seera-ASM",
@ -93,7 +103,38 @@
"description": "Description", "description": "Description",
"assignedTo": "Assigned To", "assignedTo": "Assigned To",
"scheduledDate": "Scheduled Date", "scheduledDate": "Scheduled Date",
"completedDate": "Completed Date" "completedDate": "Completed Date",
"hospital": "Hospital",
"assetType": "Asset Type",
"siteName": "Site Name",
"assignedSupervisor": "Assigned Supervisor",
"assignedContractor": "Assigned Contractor",
"serialNumberShort": "Serial",
"departmentShort": "Dept",
"manufacturerShort": "Mfr",
"workOrderIdShort": "WO ID",
"assetShort": "Asset",
"typeShort": "Type",
"nameShort": "Name",
"pmId": "PM ID",
"name": "Name"
},
"filters": {
"assetId": "Asset ID",
"hospital": "Hospital",
"name": "Name",
"serial": "Serial",
"status": "Status",
"location": "Location",
"dept": "Dept",
"modality": "Modality",
"mfr": "Mfr",
"supplier": "Supplier",
"workOrderId": "WO ID",
"asset": "Asset",
"type": "Type",
"priority": "Priority",
"allManufacturers": "All Manufacturers"
}, },
"listPages": { "listPages": {
"addNew": "Add New", "addNew": "Add New",
@ -126,12 +167,33 @@
"exportComplete": "Export Complete", "exportComplete": "Export Complete",
"close": "Close", "close": "Close",
"loading": "Loading...", "loading": "Loading...",
"refresh": "Refresh" "refresh": "Refresh",
"typing": "typing...",
"allStatuses": "All Statuses"
}, },
"assets": { "assets": {
"title": "Assets", "title": "Assets",
"addAsset": "Add New Asset", "addAsset": "Add New Asset",
"assetDetails": "Asset Details" "assetDetails": "Asset Details",
"assetInformation": "Asset Information",
"newAsset": "New Asset",
"duplicateAsset": "Duplicate Asset",
"fromAsset": "From Asset",
"creatingFromAsset": "Creating Work Order from Asset",
"assetInfoPrefilled": "Asset information prefilled from",
"pleaseSelectWorkOrderType": "Please select a Work Order type and add any additional details",
"loadingAssetDetails": "Loading asset details...",
"pleaseEnterAssetName": "Please enter an Asset Name",
"pleaseSelectCategory": "Please select a Category",
"assetDuplicatedSuccessfully": "Asset duplicated successfully!",
"assetCreatedSuccessfully": "Asset created successfully!",
"assetUpdatedSuccessfully": "Asset updated successfully!",
"sourceAssetNotFound": "Source Asset Not Found",
"assetNotFoundMessage": "The asset you're trying to duplicate could not be found.",
"backToAssetsList": "Back to Assets List",
"newAssetDetails": "New Asset Details",
"noAssetsFound": "No assets found",
"createFirstAsset": "Create your first asset"
}, },
"workOrders": { "workOrders": {
"title": "Work Orders", "title": "Work Orders",
@ -150,8 +212,15 @@
"ppm": { "ppm": {
"title": "PPM", "title": "PPM",
"ppmDetails": "PPM Details", "ppmDetails": "PPM Details",
"addPPM": "Add New PPM" "addPPM": "Add New PPM",
"periodicity": "Periodicity",
"dueDate": "Due Date",
"manageSchedules": "Manage PM Schedules",
"pmId": "PM ID",
"name": "Name",
"manufacturer": "Manufacturer"
}, },
"exportModal": { "exportModal": {
"title": "Export", "title": "Export",
"whatToExport": "What to Export", "whatToExport": "What to Export",
@ -170,6 +239,212 @@
"exportingAll": "Exporting all {count} row(s)", "exportingAll": "Exporting all {count} row(s)",
"selected": "selected", "selected": "selected",
"rows": "rows" "rows": "rows"
},
"items": {
"title": "Items",
"itemDetails": "Item Details",
"newItem": "New Item",
"addItem": "Add New Item",
"itemId": "Item ID",
"itemCode": "Item Code",
"itemName": "Item Name",
"itemGroup": "Item Group",
"stockUOM": "Stock UOM",
"partDescription": "Part Description",
"brand": "Brand",
"valuationRate": "Valuation Rate",
"openingStock": "Opening Stock",
"lastCalibrationDate": "Last Calibration Date",
"nextCalibrationDate": "Next Calibration Date",
"selectItem": "Select Item",
"selectItemGroup": "Select Item Group",
"selectHospital": "Select Hospital",
"viewDetails": "View Details",
"editItem": "Edit Item",
"duplicateItem": "Duplicate Item",
"deleteItem": "Delete Item",
"basicInformation": "Basic Information",
"stockInformation": "Stock Information",
"isStockItem": "Is Stock Item",
"balanceQty": "Balance Qty",
"calibrationInformation": "Calibration Information",
"additionalInformation": "Additional Information",
"refreshBalanceQty": "Refresh Balance Qty",
"warrantyMonths": "Warranty (Months)"
},
"issues": {
"title": "Issues",
"issueDetails": "Issue Details",
"newIssue": "New Issue",
"addIssue": "Add New Issue",
"issueId": "Issue ID",
"subject": "Subject",
"raisedBy": "Raised By",
"contact": "Contact",
"issueType": "Issue Type",
"openingDate": "Opening Date",
"resolutionDate": "Resolution Date",
"resolvedBy": "Resolved By",
"firstRespondedOn": "First Responded On",
"resolutionDetails": "Resolution Details",
"selectIssue": "Select Issue",
"allPriorities": "All Priorities",
"allCompanies": "All Companies",
"viewDetails": "View Details",
"editIssue": "Edit Issue",
"deleteIssue": "Delete Issue",
"enterSubject": "Enter issue subject",
"selectPriority": "Select priority",
"selectIssueType": "Select issue type",
"describeIssue": "Describe the issue in detail...",
"contactInformation": "Contact Information",
"createNewIssue": "Create a new support issue",
"resolution": "Resolution",
"describeResolution": "Describe how the issue was resolved...",
"selectCompany": "Select company",
"statusInformation": "Status Information",
"currentStatus": "Current Status",
"timeline": "Timeline"
},
"maintenance": {
"title": "Asset Maintenance",
"maintenanceLogs": "Maintenance Logs",
"maintenanceDetails": "Maintenance Details",
"addMaintenance": "Add New Maintenance",
"maintenanceTeam": "Maintenance Team",
"newMaintenanceTeam": "New Maintenance Team",
"teamId": "Team ID",
"teamName": "Team Name",
"managerEmail": "Manager Email",
"managerName": "Manager Name",
"expertise": "Expertise",
"selectTeam": "Select Team",
"viewDetails": "View Details",
"editTeam": "Edit Team",
"duplicateTeam": "Duplicate Team",
"deleteTeam": "Delete Team",
"selectHospital":"Select Hospital",
"selectExpertise":"Select Expertise",
"selectManager":"Select Manager",
"enterTeamName":"Enter Team Name",
"teamInformation":"Team Information",
"selectUser": "Select User",
"selectRole":"Select Role",
"totalMembers": "Total Members",
"teamSummary" : "Team Summary",
"addFirstMember":"Add First Member",
"manager":"Maintenance Manager"
},
"users": {
"title": "Users",
"userDetails": "User Details",
"newUser": "New User",
"addUser": "Add New User"
},
"events": {
"title": "Events",
"eventDetails": "Event Details",
"newEvent": "New Event",
"addEvent": "Add New Event"
},
"listPages": {
"addNew": "Add New",
"searchPlaceholder": "Search...",
"noResults": "No results found",
"showing": "Showing",
"of": "of",
"results": "results",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"selected": "selected",
"actions": "Actions",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"duplicate": "Duplicate",
"export": "Export",
"print": "Print",
"filters": "Filters",
"clearFilters": "Clear Filters",
"applyFilters": "Apply Filters",
"columns": "Columns",
"exportSelected": "Export Selected",
"exportAllOnPage": "Export All on Page",
"exportAllWithFilters": "Export All with Filters",
"exportFormat": "Export Format",
"csv": "CSV",
"excel": "Excel",
"exporting": "Exporting...",
"exportComplete": "Export Complete",
"close": "Close",
"loading": "Loading...",
"refresh": "Refresh",
"deselectAllTitle": "Deselect all",
"selectAllTitle": "Select all",
"typeToSearch": "Type to search...",
"enterFilterName": "Enter filter name",
"enterFilterNameExample": "Enter filter name (e.g., 'Open High Priority')",
"allStatuses": "All Statuses",
"noIssuesFound": "No issues found",
"clearFilters": "Clear filters",
"createFirstIssue": "Create your first issue",
"saveFilterPreset": "Save Filter Preset",
"saveFilter": "Save Filter",
"filtering": "Filtering...",
"noMaintenanceTeamsFound": "No maintenance teams found",
"createFirstTeam": "Create your first team",
"all": "All",
"tryAdjustingFilters": "Try adjusting your search or filters",
"getStartedCreateFirst": "Get started by creating your first PPM Planner",
"noMaintenanceLogsFound": "No maintenance logs found",
"createFirstMaintenanceLog": "Create your first maintenance log",
"total": "Total",
"noPPMSchedulesFound": "No PPM schedules found",
"createFirstPPMSchedule": "Create your first PPM schedule"
},
"filters": {
"assetId": "Asset ID",
"hospital": "Hospital",
"name": "Name",
"serial": "Serial",
"status": "Status",
"location": "Location",
"dept": "Dept",
"modality": "Modality",
"mfr": "Mfr",
"supplier": "Supplier",
"workOrderId": "WO ID",
"asset": "Asset",
"type": "Type",
"priority": "Priority",
"allHospitals": "All Hospitals",
"allModalities": "All Modalities",
"filterByCompany": "Filter by Company",
"allManufacturers": "All Manufacturers"
},
"users": {
"title": "Users",
"userDetails": "User Details",
"newUser": "New User",
"addUser": "Add New User",
"searchUsers": "Search users...",
"manageUsers": "Manage user accounts and permissions",
"noUsersFound": "No users found",
"tryAdjustingSearch": "Try adjusting your search terms.",
"noUsersAvailable": "No users available.",
"backToDashboard": "Back to Dashboard"
},
"events": {
"title": "Events",
"eventDetails": "Event Details",
"newEvent": "New Event",
"addEvent": "Add New Event",
"upcomingEvents": "Upcoming Events",
"eventsFromFrappe": "Events from your Frappe backend",
"noEventsFound": "No events found",
"noEventsScheduled": "No events are currently scheduled.",
"refreshEvents": "Refresh Events"
} }
} }

View File

@ -1,12 +1,14 @@
/** /**
* Active Map Page * Active Map Page
* *
* Displays hospitals/locations on an interactive map with markers showing: * Displays hospitals and PHCC locations on an interactive map with markers showing:
* - Asset counts * - Asset counts
* - Work Order counts (Normal/Urgent, by status) * - Work Order counts (Normal/Urgent, by status)
* - Maintenance Log counts (Planned/Completed/Overdue) * - Maintenance Log counts (Planned/Completed/Overdue)
* *
* Replicates the Frappe "active-map" page functionality * Supports both Hospital and PHCC location types with different field mappings:
* - Hospital: company field for assets/work orders, custom_hospital_name for maintenance
* - PHCC: custom_site for assets, site_name for work orders, asset-based for maintenance
*/ */
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
@ -37,6 +39,7 @@ interface LocationData {
name: string; name: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
location_type: 'hospital' | 'phcc';
assets: number; assets: number;
normal_work_orders: number; normal_work_orders: number;
urgent_work_orders: number; urgent_work_orders: number;
@ -47,6 +50,7 @@ interface LocationData {
wo_progress: number; wo_progress: number;
wo_review: number; wo_review: number;
wo_completed: number; wo_completed: number;
phcc_asset_names?: string[];
} }
// Component to handle map bounds fitting // Component to handle map bounds fitting
@ -73,33 +77,17 @@ const MapBounds: React.FC<{ locations: LocationData[] }> = ({ locations }) => {
const ActiveMap: React.FC = () => { const ActiveMap: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedHospital, setSelectedHospital] = useState<string>(''); const [selectedHospital, setSelectedHospital] = useState<string>('');
const [selectedPHCC, setSelectedPHCC] = useState<string>('');
const [locations, setLocations] = useState<LocationData[]>([]); const [locations, setLocations] = useState<LocationData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const markersRef = useRef<Record<string, L.Marker>>({}); const markersRef = useRef<Record<string, L.Marker>>({});
// Fetch locations and their counts // Fetch location counts based on location type
const fetchAndRenderData = async () => { const fetchLocationCounts = async (location: any, locationType: 'hospital' | 'phcc'): Promise<LocationData> => {
setLoading(true); const isPhcc = locationType === 'phcc';
try { const assetFilterField = isPhcc ? 'custom_site' : 'company';
// Build filters const woFilterField = isPhcc ? 'site_name' : 'company';
const filters: Record<string, any> = {
latitude: ['!=', ''],
longitude: ['!=', '']
};
if (selectedHospital) {
filters.name = selectedHospital;
}
// Fetch locations
const locationsResponse = await apiService.apiCall<any>(
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=["name","latitude","longitude"]`
);
const locationList = locationsResponse?.data || [];
// For each location, fetch counts
const locationPromises = locationList.map(async (location: any) => {
const counts: Partial<LocationData> = { const counts: Partial<LocationData> = {
assets: 0, assets: 0,
normal_work_orders: 0, normal_work_orders: 0,
@ -110,20 +98,27 @@ const ActiveMap: React.FC = () => {
wo_open: 0, wo_open: 0,
wo_progress: 0, wo_progress: 0,
wo_review: 0, wo_review: 0,
wo_completed: 0 wo_completed: 0,
phcc_asset_names: []
}; };
try { try {
// Fetch Asset count - use fields=["name"] to minimize data transfer // Fetch Asset count
const assetsResponse = await apiService.apiCall<any>( const assetsResponse = await apiService.apiCall<any>(
`/api/resource/Asset?filters=${encodeURIComponent(JSON.stringify({ company: location.name }))}&fields=["name"]` `/api/resource/Asset?filters=${encodeURIComponent(JSON.stringify({ [assetFilterField]: location.name }))}&fields=["name"]&limit_page_length=0`
); );
counts.assets = assetsResponse?.data?.length || 0; const assetList = assetsResponse?.data || [];
counts.assets = assetList.length;
// Store asset names for PHCC (needed for maintenance log queries)
if (isPhcc) {
counts.phcc_asset_names = assetList.map((a: any) => a.name);
}
// Fetch Normal Work Orders // Fetch Normal Work Orders
const normalWOResponse = await apiService.apiCall<any>( const normalWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({ `/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name, [woFilterField]: location.name,
custom_priority_: 'Normal', custom_priority_: 'Normal',
repair_status: ['in', ['Open', 'Work In Progress']] repair_status: ['in', ['Open', 'Work In Progress']]
}))}&fields=["name"]` }))}&fields=["name"]`
@ -133,7 +128,7 @@ const ActiveMap: React.FC = () => {
// Fetch Urgent Work Orders // Fetch Urgent Work Orders
const urgentWOResponse = await apiService.apiCall<any>( const urgentWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({ `/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name, [woFilterField]: location.name,
custom_priority_: 'Urgent', custom_priority_: 'Urgent',
repair_status: ['in', ['Open', 'Work In Progress']] repair_status: ['in', ['Open', 'Work In Progress']]
}))}&fields=["name"]` }))}&fields=["name"]`
@ -141,55 +136,34 @@ const ActiveMap: React.FC = () => {
counts.urgent_work_orders = urgentWOResponse?.data?.length || 0; counts.urgent_work_orders = urgentWOResponse?.data?.length || 0;
// Fetch WO Status counts // Fetch WO Status counts
const [woOpen, woProgress, woReview, woCompleted, plannedPM, completedPM, overduePM] = await Promise.all([ const [woOpen, woProgress, woReview, woCompleted] = await Promise.all([
// Open // Open
apiService.apiCall<any>( apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({ `/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name, [woFilterField]: location.name,
repair_status: 'Open' repair_status: 'Open'
}))}&fields=["name"]` }))}&fields=["name"]`
), ),
// Work In Progress // Work In Progress
apiService.apiCall<any>( apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({ `/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name, [woFilterField]: location.name,
repair_status: 'Work In Progress' repair_status: 'Work In Progress'
}))}&fields=["name"]` }))}&fields=["name"]`
), ),
// Pending Review // Pending Review
apiService.apiCall<any>( apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({ `/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name, [woFilterField]: location.name,
repair_status: 'Pending Review' repair_status: 'Pending Review'
}))}&fields=["name"]` }))}&fields=["name"]`
), ),
// Completed // Completed
apiService.apiCall<any>( apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({ `/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name, [woFilterField]: location.name,
repair_status: 'Completed' repair_status: 'Completed'
}))}&fields=["name"]` }))}&fields=["name"]`
),
// Planned Maintenance
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Planned'
}))}&fields=["name"]`
),
// Completed Maintenance
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Completed'
}))}&fields=["name"]`
),
// Overdue Maintenance
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Overdue'
}))}&fields=["name"]`
) )
]); ]);
@ -197,9 +171,59 @@ const ActiveMap: React.FC = () => {
counts.wo_progress = woProgress?.data?.length || 0; counts.wo_progress = woProgress?.data?.length || 0;
counts.wo_review = woReview?.data?.length || 0; counts.wo_review = woReview?.data?.length || 0;
counts.wo_completed = woCompleted?.data?.length || 0; counts.wo_completed = woCompleted?.data?.length || 0;
// Fetch Maintenance counts - different logic for PHCC vs Hospital
if (isPhcc && counts.phcc_asset_names && counts.phcc_asset_names.length > 0) {
// For PHCC, filter by asset_name
const [plannedPM, completedPM, overduePM] = await Promise.all([
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
asset_name: ['in', counts.phcc_asset_names],
maintenance_status: 'Planned'
}))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
asset_name: ['in', counts.phcc_asset_names],
maintenance_status: 'Completed'
}))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
asset_name: ['in', counts.phcc_asset_names],
maintenance_status: 'Overdue'
}))}&fields=["name"]`
)
]);
counts.planned_maintenance = plannedPM?.data?.length || 0; counts.planned_maintenance = plannedPM?.data?.length || 0;
counts.completed_maintenance = completedPM?.data?.length || 0; counts.completed_maintenance = completedPM?.data?.length || 0;
counts.overdue_maintenance = overduePM?.data?.length || 0; counts.overdue_maintenance = overduePM?.data?.length || 0;
} else if (!isPhcc) {
// For Hospital, filter by custom_hospital_name
const [plannedPM, completedPM, overduePM] = await Promise.all([
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Planned'
}))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Completed'
}))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Overdue'
}))}&fields=["name"]`
)
]);
counts.planned_maintenance = plannedPM?.data?.length || 0;
counts.completed_maintenance = completedPM?.data?.length || 0;
counts.overdue_maintenance = overduePM?.data?.length || 0;
}
} catch (err) { } catch (err) {
console.error(`Error fetching counts for ${location.name}:`, err); console.error(`Error fetching counts for ${location.name}:`, err);
} }
@ -208,12 +232,69 @@ const ActiveMap: React.FC = () => {
name: location.name, name: location.name,
latitude: parseFloat(location.latitude), latitude: parseFloat(location.latitude),
longitude: parseFloat(location.longitude), longitude: parseFloat(location.longitude),
location_type: locationType,
...counts ...counts
} as LocationData; } as LocationData;
}); };
const results = await Promise.all(locationPromises); // Fetch locations and their counts
setLocations(results.filter(l => !isNaN(l.latitude) && !isNaN(l.longitude))); const fetchAndRenderData = async () => {
setLoading(true);
try {
let allLocations: LocationData[] = [];
const fetchPromises: Promise<LocationData[]>[] = [];
// Fetch Hospital locations (if no PHCC is specifically selected, or if hospital is selected)
if (!selectedPHCC || selectedHospital) {
const hospitalFilters: Record<string, any> = {
latitude: ['!=', ''],
longitude: ['!=', ''],
custom_is_hospital: 1
};
if (selectedHospital) {
hospitalFilters.name = selectedHospital;
}
fetchPromises.push(
(async () => {
const locationsResponse = await apiService.apiCall<any>(
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(hospitalFilters))}&fields=["name","latitude","longitude"]&limit_page_length=0`
);
const locationList = locationsResponse?.data || [];
const locationPromises = locationList.map((loc: any) => fetchLocationCounts(loc, 'hospital'));
return Promise.all(locationPromises);
})()
);
}
// Fetch PHCC locations (if no hospital is specifically selected, or if PHCC is selected)
if (!selectedHospital || selectedPHCC) {
const phccFilters: Record<string, any> = {
latitude: ['!=', ''],
longitude: ['!=', ''],
custom_is_phcc: 1
};
if (selectedPHCC) {
phccFilters.name = selectedPHCC;
}
fetchPromises.push(
(async () => {
const locationsResponse = await apiService.apiCall<any>(
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(phccFilters))}&fields=["name","latitude","longitude"]&limit_page_length=0`
);
const locationList = locationsResponse?.data || [];
const locationPromises = locationList.map((loc: any) => fetchLocationCounts(loc, 'phcc'));
return Promise.all(locationPromises);
})()
);
}
const results = await Promise.all(fetchPromises);
allLocations = results.flat().filter(l => !isNaN(l.latitude) && !isNaN(l.longitude));
setLocations(allLocations);
} catch (error) { } catch (error) {
console.error('Error fetching map data:', error); console.error('Error fetching map data:', error);
} finally { } finally {
@ -223,38 +304,57 @@ const ActiveMap: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchAndRenderData(); fetchAndRenderData();
}, [selectedHospital]); }, [selectedHospital, selectedPHCC]);
// Navigate to list view with filters // Navigate to list view with filters
const navigateToWorkOrders = (hospital: string, priority?: string, status?: string) => { const navigateToWorkOrders = (location: LocationData, priority?: string, status?: string) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (hospital) params.set('company', hospital); const filterField = location.location_type === 'phcc' ? 'site_name' : 'company';
params.set(filterField, location.name);
if (priority) params.set('priority', priority); if (priority) params.set('priority', priority);
if (status) params.set('status', status); if (status) params.set('status', status);
navigate(`/work-orders?${params.toString()}`); navigate(`/work-orders?${params.toString()}`);
}; };
const navigateToAssets = (hospital: string) => { const navigateToAssets = (location: LocationData) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (hospital) params.set('company', hospital); const filterField = location.location_type === 'phcc' ? 'custom_site' : 'company';
params.set(filterField, location.name);
navigate(`/assets?${params.toString()}`); navigate(`/assets?${params.toString()}`);
}; };
const navigateToMaintenanceCalendar = (hospital: string, status?: string) => { const navigateToMaintenanceCalendar = (location: LocationData, status?: string) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (hospital) params.set('hospital', hospital); if (location.location_type === 'phcc') {
// For PHCC, we need to pass asset names or use a different approach
params.set('phcc', location.name);
} else {
params.set('hospital', location.name);
}
if (status) params.set('status', status); if (status) params.set('status', status);
navigate(`/maintenance-calendar?${params.toString()}`); navigate(`/maintenance-calendar?${params.toString()}`);
}; };
// Create popup content with modern UI matching the application // Create popup content with modern UI matching the application
const createPopupContent = (location: LocationData) => { const createPopupContent = (location: LocationData) => {
const isPhcc = location.location_type === 'phcc';
const typeBadge = isPhcc ? (
<span className="ml-2 px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-semibold rounded-full">
PHCC
</span>
) : (
<span className="ml-2 px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-semibold rounded-full">
Hospital
</span>
);
return ( return (
<div className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg min-w-[280px] max-w-[320px]"> <div className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg min-w-[280px] max-w-[320px]">
{/* Hospital Name Header */} {/* Location Name Header */}
<div className="mb-4 pb-3 border-b border-gray-200 dark:border-gray-700"> <div className="mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-bold text-gray-900 dark:text-white"> <h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center flex-wrap">
{location.name} {location.name}
{typeBadge}
</h3> </h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Total Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span> Total Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span>
@ -268,13 +368,13 @@ const ActiveMap: React.FC = () => {
</h4> </h4>
<div className="flex gap-2 mb-3"> <div className="flex gap-2 mb-3">
<button <button
onClick={() => navigateToWorkOrders(location.name, 'Normal')} onClick={() => navigateToWorkOrders(location, 'Normal')}
className="px-3 py-1.5 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer" className="px-3 py-1.5 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
> >
Normal: {location.normal_work_orders} Normal: {location.normal_work_orders}
</button> </button>
<button <button
onClick={() => navigateToWorkOrders(location.name, 'Urgent')} onClick={() => navigateToWorkOrders(location, 'Urgent')}
className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer" className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
> >
Urgent: {location.urgent_work_orders} Urgent: {location.urgent_work_orders}
@ -295,7 +395,7 @@ const ActiveMap: React.FC = () => {
<td className="px-3 py-2 text-red-800 dark:text-red-300 font-medium">Open</td> <td className="px-3 py-2 text-red-800 dark:text-red-300 font-medium">Open</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<button <button
onClick={() => navigateToWorkOrders(location.name, undefined, 'Open')} onClick={() => navigateToWorkOrders(location, undefined, 'Open')}
className="text-red-700 dark:text-red-400 font-bold hover:underline cursor-pointer" className="text-red-700 dark:text-red-400 font-bold hover:underline cursor-pointer"
> >
{location.wo_open} {location.wo_open}
@ -306,7 +406,7 @@ const ActiveMap: React.FC = () => {
<td className="px-3 py-2 text-yellow-800 dark:text-yellow-300 font-medium">Work In Progress</td> <td className="px-3 py-2 text-yellow-800 dark:text-yellow-300 font-medium">Work In Progress</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<button <button
onClick={() => navigateToWorkOrders(location.name, undefined, 'Work In Progress')} onClick={() => navigateToWorkOrders(location, undefined, 'Work In Progress')}
className="text-yellow-700 dark:text-yellow-400 font-bold hover:underline cursor-pointer" className="text-yellow-700 dark:text-yellow-400 font-bold hover:underline cursor-pointer"
> >
{location.wo_progress} {location.wo_progress}
@ -317,7 +417,7 @@ const ActiveMap: React.FC = () => {
<td className="px-3 py-2 text-blue-800 dark:text-blue-300 font-medium">Pending Review</td> <td className="px-3 py-2 text-blue-800 dark:text-blue-300 font-medium">Pending Review</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<button <button
onClick={() => navigateToWorkOrders(location.name, undefined, 'Pending Review')} onClick={() => navigateToWorkOrders(location, undefined, 'Pending Review')}
className="text-blue-700 dark:text-blue-400 font-bold hover:underline cursor-pointer" className="text-blue-700 dark:text-blue-400 font-bold hover:underline cursor-pointer"
> >
{location.wo_review} {location.wo_review}
@ -328,7 +428,7 @@ const ActiveMap: React.FC = () => {
<td className="px-3 py-2 text-green-800 dark:text-green-300 font-medium">Completed</td> <td className="px-3 py-2 text-green-800 dark:text-green-300 font-medium">Completed</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<button <button
onClick={() => navigateToWorkOrders(location.name, undefined, 'Completed')} onClick={() => navigateToWorkOrders(location, undefined, 'Completed')}
className="text-green-700 dark:text-green-400 font-bold hover:underline cursor-pointer" className="text-green-700 dark:text-green-400 font-bold hover:underline cursor-pointer"
> >
{location.wo_completed} {location.wo_completed}
@ -347,19 +447,19 @@ const ActiveMap: React.FC = () => {
</h4> </h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={() => navigateToMaintenanceCalendar(location.name, 'Planned')} onClick={() => navigateToMaintenanceCalendar(location, 'Planned')}
className="px-3 py-1.5 bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer" className="px-3 py-1.5 bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
> >
Planned: {location.planned_maintenance} Planned: {location.planned_maintenance}
</button> </button>
<button <button
onClick={() => navigateToMaintenanceCalendar(location.name, 'Completed')} onClick={() => navigateToMaintenanceCalendar(location, 'Completed')}
className="px-3 py-1.5 bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50 text-green-700 dark:text-green-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer" className="px-3 py-1.5 bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50 text-green-700 dark:text-green-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
> >
Completed: {location.completed_maintenance} Completed: {location.completed_maintenance}
</button> </button>
<button <button
onClick={() => navigateToMaintenanceCalendar(location.name, 'Overdue')} onClick={() => navigateToMaintenanceCalendar(location, 'Overdue')}
className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer" className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
> >
Overdue: {location.overdue_maintenance} Overdue: {location.overdue_maintenance}
@ -370,13 +470,13 @@ const ActiveMap: React.FC = () => {
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex gap-2 pt-3 border-t border-gray-200 dark:border-gray-700"> <div className="flex gap-2 pt-3 border-t border-gray-200 dark:border-gray-700">
<button <button
onClick={() => navigateToAssets(location.name)} onClick={() => navigateToAssets(location)}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer" className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
> >
View Assets View Assets
</button> </button>
<button <button
onClick={() => navigateToWorkOrders(location.name)} onClick={() => navigateToWorkOrders(location)}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 dark:bg-purple-700 dark:hover:bg-purple-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer" className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 dark:bg-purple-700 dark:hover:bg-purple-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
> >
View Work Orders View Work Orders
@ -394,22 +494,37 @@ const ActiveMap: React.FC = () => {
{/* Filter Container */} {/* Filter Container */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 relative z-[1000]"> <div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 relative z-[1000]">
<div className="max-w-md relative z-[1000]"> <div className="flex flex-wrap gap-4 relative z-[1000]">
{/* Hospital Filter */}
<div className="w-64 relative z-[1000]">
<LinkField <LinkField
label="Hospital" label="Hospital"
doctype="Location" doctype="Location"
value={selectedHospital} value={selectedHospital}
onChange={setSelectedHospital} onChange={setSelectedHospital}
filters={{ custom_is_hospital: 1 }} filters={{ custom_is_hospital: 1 }}
placeholder="Select hospital (leave empty for all)" placeholder="Select hospital"
/> />
</div> </div>
{/* PHCC Filter */}
<div className="w-64 relative z-[1000]">
<LinkField
label="PHCC"
doctype="Location"
value={selectedPHCC}
onChange={setSelectedPHCC}
filters={{ custom_is_phcc: 1 }}
placeholder="Select PHCC"
/>
</div>
</div>
</div> </div>
{/* Map Container */} {/* Map Container */}
<div className="flex-1 relative" style={{ zIndex: 1 }}> <div className="flex-1 relative" style={{ zIndex: 1 }}>
{loading && ( {loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 z-[1000]"> <div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 z-[1000]">
<div className="text-gray-600 dark:text-gray-300">Loading map data...</div> <div className="text-gray-600 dark:text-gray-300">Loading map data...</div>
</div> </div>
)} )}
@ -426,7 +541,9 @@ const ActiveMap: React.FC = () => {
<MapBounds locations={locations} /> <MapBounds locations={locations} />
{locations.map((location) => { {locations.map((location) => {
const urgentIndicator = location.urgent_work_orders > 0 ? '🚨 URGENT! ' : ''; const urgentIndicator = location.urgent_work_orders > 0 ? '🚨 URGENT! ' : '';
const markerKey = `${location.latitude}-${location.longitude}`; const isPhcc = location.location_type === 'phcc';
const typeIndicator = isPhcc ? '🏥 PHCC' : '🏨 Hospital';
const markerKey = `${location.name}-${location.latitude}-${location.longitude}`;
return ( return (
<Marker <Marker
@ -435,16 +552,24 @@ const ActiveMap: React.FC = () => {
ref={(ref) => { ref={(ref) => {
if (ref) { if (ref) {
markersRef.current[markerKey] = ref; markersRef.current[markerKey] = ref;
// Apply urgent marker styling // Apply marker styling based on location type and urgency
if (location.urgent_work_orders > 0) {
setTimeout(() => { setTimeout(() => {
const markerElement = ref.getElement(); const markerElement = ref.getElement();
if (markerElement) { if (markerElement) {
// Remove all custom classes first
markerElement.classList.remove('urgent-marker', 'red-marker', 'phcc-marker');
if (location.urgent_work_orders > 0) {
// Same red flashing for both Hospital and PHCC urgent markers
markerElement.classList.add('urgent-marker', 'red-marker'); markerElement.classList.add('urgent-marker', 'red-marker');
} else if (isPhcc) {
// Green marker for non-urgent PHCC
markerElement.classList.add('phcc-marker');
}
// Non-urgent hospitals use default blue marker
} }
}, 100); }, 100);
} }
}
}} }}
> >
<Tooltip <Tooltip
@ -457,6 +582,9 @@ const ActiveMap: React.FC = () => {
<h4 className="text-sm font-bold text-gray-900 dark:text-white"> <h4 className="text-sm font-bold text-gray-900 dark:text-white">
{urgentIndicator}{location.name} {urgentIndicator}{location.name}
</h4> </h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{typeIndicator}
</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5"> <p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span> Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span>
</p> </p>
@ -482,9 +610,9 @@ const ActiveMap: React.FC = () => {
</div> </div>
</Tooltip> </Tooltip>
<Popup <Popup
className="hospital-popup-container" className={isPhcc ? "phcc-popup-container" : "hospital-popup-container"}
maxWidth={300} maxWidth={320}
maxHeight={410} maxHeight={450}
autoPan={true} autoPan={true}
keepInView={true} keepInView={true}
closeButton={true} closeButton={true}
@ -551,23 +679,44 @@ const ActiveMap: React.FC = () => {
width: auto !important; width: auto !important;
} }
/* PHCC Popup Container - with green left border */
.phcc-popup-container .leaflet-popup-content-wrapper {
padding: 0;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-left: 4px solid #28a745;
}
.phcc-popup-container .leaflet-popup-content {
margin: 0;
width: auto !important;
}
/* Red Flashing Animation for Urgent Work Orders (Both Hospital & PHCC) */
/* Fixed: Stays red throughout, just pulses brighter */
.urgent-marker { .urgent-marker {
animation: urgent-flash 2s infinite; animation: urgent-flash 2s infinite;
} }
@keyframes urgent-flash { @keyframes urgent-flash {
0%, 50% { 0%, 50% {
filter: hue-rotate(0deg) brightness(1) saturate(1); filter: hue-rotate(120deg) saturate(2) brightness(0.8);
} }
25%, 75% { 25%, 75% {
filter: hue-rotate(0deg) brightness(1.5) saturate(2) drop-shadow(0 0 10px red); filter: hue-rotate(120deg) saturate(2.5) brightness(1.5) drop-shadow(0 0 10px red);
} }
} }
/* Red marker style (base state for urgent) */
.red-marker { .red-marker {
filter: hue-rotate(120deg) saturate(2) brightness(0.8); filter: hue-rotate(120deg) saturate(2) brightness(0.8);
} }
/* Green marker style for PHCC */
.phcc-marker {
filter: hue-rotate(-120deg) saturate(1.3) brightness(1.1);
}
.leaflet-popup { .leaflet-popup {
z-index: 2000 !important; z-index: 2000 !important;
} }
@ -581,4 +730,3 @@ const ActiveMap: React.FC = () => {
}; };
export default ActiveMap; export default ActiveMap;

View File

@ -2725,21 +2725,33 @@ const handlePPMPlan = async () => {
{/* Site Name - conditionally visible based on depends_on: company.startsWith('Mobile') */} {/* Site Name - conditionally visible based on depends_on: company.startsWith('Mobile') */}
{shouldShowField('custom_site') && ( {shouldShowField('custom_site') && (
// <div>
// <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
// Site Name {isFieldMandatory('custom_site') && <span className="text-red-500">*</span>}
// </label>
// <input
// type="text"
// name="custom_site"
// value={formData.custom_site || ''}
// onChange={handleChange}
// placeholder="Site name"
// disabled={isFieldDisabled('custom_site')}
// required={isFieldMandatory('custom_site')}
// 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>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"> <LinkField
Site Name {isFieldMandatory('custom_site') && <span className="text-red-500">*</span>} label="Site name"
</label> doctype="Mobile Team Site"
<input
type="text"
name="custom_site"
value={formData.custom_site || ''} value={formData.custom_site || ''}
onChange={handleChange} onChange={(val) => setFormData({ ...formData, custom_site: val })}
placeholder="Site name"
disabled={isFieldDisabled('custom_site')} disabled={isFieldDisabled('custom_site')}
required={isFieldMandatory('custom_site')} placeholder="Select Site"
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 <LinkField

View File

@ -829,6 +829,7 @@ const AssetList: React.FC = () => {
if (asset.company) params.append('company', asset.company); if (asset.company) params.append('company', asset.company);
if (asset.custom_service_agreement) params.append('custom_service_agreement', asset.custom_service_agreement); if (asset.custom_service_agreement) params.append('custom_service_agreement', asset.custom_service_agreement);
if (asset.custom_service_coverage) params.append('custom_service_coverage', asset.custom_service_coverage); if (asset.custom_service_coverage) params.append('custom_service_coverage', asset.custom_service_coverage);
if (asset.custom_site) params.append('site_name', asset.custom_site);
navigate(`/work-orders/new?${params.toString()}`); navigate(`/work-orders/new?${params.toString()}`);
}; };

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMaintenanceLogDetails, useMaintenanceMutations } from '../hooks/useAssetMaintenance'; import { useMaintenanceLogDetails, useMaintenanceMutations } from '../hooks/useAssetMaintenance';
import { FaArrowLeft, FaSave, FaEdit, FaClock, FaList, FaPlus, FaTrash, FaCheck, FaTimes, FaExclamationTriangle } from 'react-icons/fa'; import { FaArrowLeft, FaSave, FaEdit, FaClock, FaList, FaPlus, FaTrash, FaCheck, FaTimes, FaExclamationTriangle } from 'react-icons/fa';
import apiService from '../services/apiService'; import apiService from '../services/apiService';
@ -77,6 +78,7 @@ const Toast: React.FC<{ message: string; type: 'warning' | 'success' | 'error';
}; };
const AssetMaintenanceDetail: React.FC = () => { const AssetMaintenanceDetail: React.FC = () => {
const { t } = useTranslation();
const { logName } = useParams<{ logName: string }>(); const { logName } = useParams<{ logName: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();

View File

@ -145,7 +145,7 @@ const AssetMaintenanceList: React.FC = () => {
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900"> <div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div> <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">Loading maintenance logs...</p> <p className="mt-4 text-gray-600 dark:text-gray-400">{t('listPages.loading')}</p>
</div> </div>
</div> </div>
); );
@ -196,7 +196,7 @@ const AssetMaintenanceList: React.FC = () => {
<div> <div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('maintenance.title')}</h1> <h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('maintenance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1"> <p className="text-gray-600 dark:text-gray-400 mt-1">
Total: {totalCount} maintenance log{totalCount !== 1 ? 's' : ''} {t('listPages.total')}: {totalCount} {t('maintenance.maintenanceLogs')}
</p> </p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@ -206,14 +206,14 @@ const AssetMaintenanceList: React.FC = () => {
disabled={logs.length === 0} disabled={logs.length === 0}
> >
<FaFileExport /> <FaFileExport />
<span className="font-medium">Export All</span> <span className="font-medium">{t('listPages.exportAllOnPage')}</span>
</button> </button>
<button <button
onClick={handleCreateNew} onClick={handleCreateNew}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl" className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
> >
<FaPlus /> <FaPlus />
<span className="font-medium">New Maintenance Log</span> <span className="font-medium">{t('maintenance.addMaintenance')}</span>
</button> </button>
</div> </div>
</div> </div>
@ -225,7 +225,7 @@ const AssetMaintenanceList: React.FC = () => {
<FaSearch className="text-gray-400 dark:text-gray-500" /> <FaSearch className="text-gray-400 dark:text-gray-500" />
<input <input
type="text" type="text"
placeholder="Search by ID, asset, task..." placeholder={t('listPages.searchPlaceholder')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent" className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
@ -242,7 +242,7 @@ const AssetMaintenanceList: React.FC = () => {
}} }}
className="w-full px-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" className="w-full px-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"
> >
<option value="">All Statuses</option> <option value="">{t('listPages.allStatuses')}</option>
<option value="Planned">Planned</option> <option value="Planned">Planned</option>
<option value="Completed">Completed</option> <option value="Completed">Completed</option>
<option value="Overdue">Overdue</option> <option value="Overdue">Overdue</option>
@ -258,22 +258,22 @@ const AssetMaintenanceList: React.FC = () => {
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600"> <thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Log ID {t('maintenance.logId')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Asset {t('commonFields.assetShort')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type {t('commonFields.typeShort')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Due Date {t('ppm.dueDate')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status {t('commonFields.status')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions {t('listPages.actions')}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -283,12 +283,12 @@ const AssetMaintenanceList: React.FC = () => {
<td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"> <td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" /> <FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>No maintenance logs found</p> <p>{t('listPages.noMaintenanceLogsFound')}</p>
<button <button
onClick={handleCreateNew} onClick={handleCreateNew}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline" className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
> >
Create your first maintenance log {t('listPages.createFirstMaintenanceLog')}
</button> </button>
</div> </div>
</td> </td>

View File

@ -0,0 +1,686 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useIssueDetails, useIssueMutations } from '../hooks/useIssue';
import {
FaArrowLeft,
FaSave,
FaEdit,
FaTrash,
FaCheckCircle,
FaTimesCircle,
FaExclamationTriangle,
FaClock,
FaUser,
FaBuilding,
FaEnvelope,
FaCalendarAlt,
FaTag,
FaComment
} from 'react-icons/fa';
import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import LinkField from '../components/LinkField';
import type { CreateIssueData } from '../services/issueService';
// Helper to get today's date in YYYY-MM-DD format
const getTodayDate = (): string => {
return new Date().toISOString().split('T')[0];
};
// Helper to get current time in HH:MM:SS format
const getCurrentTime = (): string => {
return new Date().toTimeString().split(' ')[0];
};
// Status badge styles
const getStatusStyle = (status: string) => {
switch (status?.toLowerCase()) {
case 'open':
return { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-800 dark:text-blue-300', border: 'border-blue-200 dark:border-blue-800' };
case 'replied':
return { bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-800 dark:text-purple-300', border: 'border-purple-200 dark:border-purple-800' };
case 'on hold':
return { bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-800 dark:text-yellow-300', border: 'border-yellow-200 dark:border-yellow-800' };
case 'resolved':
return { bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-800 dark:text-green-300', border: 'border-green-200 dark:border-green-800' };
case 'closed':
return { bg: 'bg-gray-100 dark:bg-gray-700', text: 'text-gray-800 dark:text-gray-300', border: 'border-gray-200 dark:border-gray-600' };
default:
return { bg: 'bg-gray-100 dark:bg-gray-700', text: 'text-gray-800 dark:text-gray-300', border: 'border-gray-200 dark:border-gray-600' };
}
};
const IssueDetail: React.FC = () => {
const { t } = useTranslation();
const { issueName } = useParams<{ issueName: string }>();
const navigate = useNavigate();
const isNewIssue = issueName === 'new';
// Form data state
const [formData, setFormData] = useState<CreateIssueData & {
opening_date?: string;
opening_time?: string;
first_responded_on?: string;
resolution_date?: string;
resolution_by?: string;
}>({
subject: '',
raised_by: '',
status: 'Open',
priority: '',
issue_type: '',
description: '',
contact: '',
company: '',
customer: '',
project: '',
resolution_details: '',
opening_date: isNewIssue ? getTodayDate() : '',
opening_time: isNewIssue ? getCurrentTime() : '',
first_responded_on: '',
resolution_date: '',
resolution_by: '',
});
const { issue, loading, error, refetch } = useIssueDetails(isNewIssue ? null : issueName || null);
const { createIssue, updateIssue, deleteIssue, loading: saving } = useIssueMutations();
const [isEditing, setIsEditing] = useState(isNewIssue);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Load issue data when fetched
useEffect(() => {
if (issue && !isNewIssue) {
setFormData({
subject: issue.subject || '',
raised_by: issue.raised_by || '',
status: issue.status || 'Open',
priority: issue.priority || '',
issue_type: issue.issue_type || '',
description: issue.description || '',
contact: issue.contact || '',
company: issue.company || '',
customer: issue.customer || '',
project: issue.project || '',
resolution_details: issue.resolution_details || '',
opening_date: issue.opening_date || '',
opening_time: issue.opening_time || '',
first_responded_on: issue.first_responded_on ? issue.first_responded_on.split(' ')[0] : '',
resolution_date: issue.resolution_date ? issue.resolution_date.split(' ')[0] : '',
resolution_by: issue.resolution_by || '',
});
setIsEditing(false);
}
}, [issue, isNewIssue]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSave = async () => {
if (!formData.subject) {
toast.error('Please enter a subject', {
position: "top-right",
autoClose: 4000,
icon: <FaTimesCircle />
});
return;
}
try {
if (isNewIssue) {
const newIssue = await createIssue(formData);
toast.success('Issue created successfully!', {
position: "top-right",
autoClose: 3000,
icon: <FaCheckCircle />
});
navigate(`/support/${newIssue.name}`);
} else {
await updateIssue(issueName!, formData);
toast.success('Issue updated successfully!', {
position: "top-right",
autoClose: 3000,
icon: <FaCheckCircle />
});
setIsEditing(false);
refetch();
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to save: ${errorMessage}`, {
position: "top-right",
autoClose: 6000,
icon: <FaTimesCircle />
});
}
};
const handleDelete = async () => {
try {
await deleteIssue(issueName!);
toast.success('Issue deleted successfully!', {
position: "top-right",
autoClose: 3000,
icon: <FaCheckCircle />
});
navigate('/support');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to delete: ${errorMessage}`, {
position: "top-right",
autoClose: 6000,
icon: <FaTimesCircle />
});
}
};
const isFieldDisabled = useCallback((fieldname: string): boolean => {
if (!isEditing) return true;
// Some fields are always read-only
if (['opening_date', 'opening_time'].includes(fieldname) && !isNewIssue) {
return true;
}
return false;
}, [isEditing, isNewIssue]);
// Format datetime
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
};
if (loading) {
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">Loading issue details...</p>
</div>
</div>
);
}
if (error && !isNewIssue) {
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-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Issue</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button
onClick={() => navigate('/support')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Back to Issues
</button>
</div>
</div>
);
}
const currentStatus = issue?.status || formData.status || 'Open';
const statusStyle = getStatusStyle(currentStatus);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
{/* Toast Container */}
<ToastContainer
position="top-right"
autoClose={4000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
transition={Bounce}
/>
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/support')}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
<FaArrowLeft size={20} />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white flex items-center gap-3">
{isNewIssue ? t('issues.newIssue') : issue?.name || t('issues.issueDetails')}
{!isNewIssue && (
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyle.bg} ${statusStyle.text} ${statusStyle.border} border`}>
{currentStatus}
</span>
)}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isNewIssue ? t('issues.createNewIssue') : formData.subject}
</p>
</div>
</div>
<div className="flex gap-3">
{!isNewIssue && !isEditing && (
<>
<button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
>
<FaEdit />
{t('common.edit')}
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
>
<FaTrash />
{t('common.delete')}
</button>
</>
)}
{isEditing && (
<>
<button
onClick={() => {
if (isNewIssue) {
navigate('/support');
} else {
setIsEditing(false);
refetch();
}
}}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? t('common.saving') : t('common.save')}
</button>
</>
)}
</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">Delete Issue</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Are you sure you want to delete this issue? This action cannot be undone.
</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"
>
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"
>
{saving ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
{/* Form */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content - Left Column */}
<div className="lg:col-span-2 space-y-6">
{/* Issue Details */}
<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-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaComment className="text-blue-500" />
{t('issues.issueDetails')}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.subject')} <span className="text-red-500">*</span>
</label>
<input
type="text"
name="subject"
value={formData.subject}
onChange={handleChange}
disabled={isFieldDisabled('subject')}
placeholder={t('issues.enterSubject')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('commonFields.status')}
</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
disabled={isFieldDisabled('status')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="Open">Open</option>
<option value="Replied">Replied</option>
<option value="On Hold">On Hold</option>
<option value="Resolved">Resolved</option>
<option value="Closed">Closed</option>
</select>
</div>
<div>
<LinkField
label={t('commonFields.priority')}
doctype="Issue Priority"
value={formData.priority || ''}
onChange={(val) => setFormData({ ...formData, priority: val })}
disabled={isFieldDisabled('priority')}
placeholder={t('issues.selectPriority')}
/>
</div>
</div>
<div>
<LinkField
label={t('issues.issueType')}
doctype="Issue Type"
value={formData.issue_type || ''}
onChange={(val) => setFormData({ ...formData, issue_type: val })}
disabled={isFieldDisabled('issue_type')}
placeholder={t('issues.selectIssueType')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('commonFields.description')}
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
disabled={isFieldDisabled('description')}
placeholder={t('issues.describeIssue')}
rows={5}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
</div>
</div>
{/* Contact 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-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaUser className="text-green-500" />
{t('issues.contactInformation')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.raisedBy')}
</label>
<div className="relative">
<FaEnvelope className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="email"
name="raised_by"
value={formData.raised_by}
onChange={handleChange}
disabled={isFieldDisabled('raised_by')}
placeholder={t('common.email')}
className="w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contact Name
</label>
<input
type="text"
name="contact"
value={formData.contact}
onChange={handleChange}
disabled={isFieldDisabled('contact')}
placeholder="Contact person name"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div> */}
<div>
<LinkField
label={t('commonFields.company')}
doctype="Company"
value={formData.company || ''}
onChange={(val) => setFormData({ ...formData, company: val })}
disabled={isFieldDisabled('company')}
placeholder={t('issues.selectCompany')}
/>
</div>
{/* <div>
<LinkField
label="Customer"
doctype="Customer"
value={formData.customer || ''}
onChange={(val) => setFormData({ ...formData, customer: val })}
disabled={isFieldDisabled('customer')}
placeholder="Select customer"
/>
</div>
<div>
<LinkField
label="Project"
doctype="Project"
value={formData.project || ''}
onChange={(val) => setFormData({ ...formData, project: val })}
disabled={isFieldDisabled('project')}
placeholder="Select project"
/>
</div> */}
</div>
</div>
{/* Resolution */}
{!isNewIssue && (
<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-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaCheckCircle className="text-purple-500" />
{t('issues.resolution')}
</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.firstRespondedOn')}
</label>
<input
type="date"
name="first_responded_on"
value={formData.first_responded_on || ''}
onChange={handleChange}
disabled={isFieldDisabled('first_responded_on')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.resolutionDate')}
</label>
<input
type="date"
name="resolution_date"
value={formData.resolution_date || ''}
onChange={handleChange}
disabled={isFieldDisabled('resolution_date')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<LinkField
label={t('issues.resolvedBy')}
doctype="User"
value={formData.resolution_by || ''}
onChange={(val) => setFormData({ ...formData, resolution_by: val })}
disabled={isFieldDisabled('resolution_by')}
placeholder={t('maintenance.selectUser')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.resolutionDetails')}
</label>
<textarea
name="resolution_details"
value={formData.resolution_details}
onChange={handleChange}
disabled={isFieldDisabled('resolution_details')}
placeholder={t('issues.describeResolution')}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
</div>
</div>
)}
</div>
{/* Sidebar - Right Column */}
<div className="space-y-6">
{/* Status 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-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaTag className="text-orange-500" />
{t('issues.statusInformation')}
</h2>
<div className="space-y-4">
<div className={`p-4 rounded-lg border ${statusStyle.bg} ${statusStyle.border}`}>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('issues.currentStatus')}</p>
<p className={`text-xl font-semibold ${statusStyle.text}`}>
{currentStatus}
</p>
</div>
{formData.priority && (
<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('commonFields.priority')}</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formData.priority}
</p>
</div>
)}
{formData.issue_type && (
<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('issues.issueType')}</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formData.issue_type}
</p>
</div>
)}
</div>
</div>
{/* Timeline 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-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaCalendarAlt className="text-teal-500" />
{t('issues.timeline')}
</h2>
<div className="space-y-4">
<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('issues.openingDate')}</p>
<p className="text-sm text-gray-900 dark:text-white">
{formData.opening_date || '-'}
</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">Opening Time</p>
<p className="text-sm text-gray-900 dark:text-white">
{formData.opening_time || '-'}
</p>
</div>
{!isNewIssue && issue && (
<>
<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">Created</p>
<p className="text-sm text-gray-900 dark:text-white">
{formatDateTime(issue.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">Last Modified</p>
<p className="text-sm text-gray-900 dark:text-white">
{formatDateTime(issue.modified)}
</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">Modified By</p>
<p className="text-sm text-gray-900 dark:text-white">
{issue.modified_by || '-'}
</p>
</div>
</>
)}
</div>
</div>
{/* Company Info Card */}
{formData.company && !isNewIssue && (
<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-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaBuilding className="text-indigo-500" />
Company
</h2>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formData.company}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default IssueDetail;

View File

@ -0,0 +1,638 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useIssueList } from '../hooks/useIssue';
import * as XLSX from 'xlsx';
import {
FaPlus,
FaFilter,
FaSync,
FaEye,
FaChevronLeft,
FaChevronRight,
FaExclamationCircle,
FaCheckCircle,
FaClock,
FaTimesCircle,
FaHeadset,
FaTimes,
FaSave,
FaStar,
FaTrash,
FaEdit,
FaCheckSquare,
FaSquare,
FaFileExport,
FaFileExcel,
FaFileCsv,
FaDownload
} from 'react-icons/fa';
import LinkField from '../components/LinkField';
// Export types
type ExportFormat = 'csv' | 'excel';
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
selectedCount: number;
totalCount: number;
pageCount: number;
onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void;
isExporting: boolean;
exportColumns: Array<{key: string, label: string, default: boolean}>;
}
const ExportModal: React.FC<ExportModalProps> = ({
isOpen,
onClose,
selectedCount,
totalCount,
pageCount,
onExport,
isExporting,
exportColumns
}) => {
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
const [format, setFormat] = useState<ExportFormat>('csv');
const [selectedColumns, setSelectedColumns] = useState<string[]>(
exportColumns.filter(c => c.default).map(c => c.key)
);
useEffect(() => {
if (selectedCount > 0) {
setScope('selected');
} else {
setScope('all_with_filters');
}
}, [selectedCount]);
const toggleColumn = (key: string) => {
setSelectedColumns(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key));
const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key));
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FaFileExport className="text-white text-xl" />
<h3 className="text-lg font-semibold text-white">Export Issues</h3>
</div>
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
<FaTimes size={20} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Select Data to Export</h4>
<div className="space-y-2">
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'selected' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
<input type="radio" name="scope" value="selected" checked={scope === 'selected'} onChange={() => setScope('selected')} disabled={selectedCount === 0} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">Selected Rows</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Export {selectedCount} selected issue{selectedCount !== 1 ? 's' : ''}</div>
</div>
{selectedCount > 0 && <span className="bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 px-2 py-1 rounded text-xs font-medium">{selectedCount} selected</span>}
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_on_page' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_on_page" checked={scope === 'all_on_page'} onChange={() => setScope('all_on_page')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">Current Page</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Export {pageCount} issue{pageCount !== 1 ? 's' : ''} on current page</div>
</div>
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">{pageCount} rows</span>
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_with_filters' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_with_filters" checked={scope === 'all_with_filters'} onChange={() => setScope('all_with_filters')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">All Records (with current filters)</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Export all {totalCount} issue{totalCount !== 1 ? 's' : ''} matching current filters</div>
</div>
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">{totalCount} total</span>
</label>
</div>
</div>
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Export Format</h4>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'csv' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="csv" checked={format === 'csv'} onChange={() => setFormat('csv')} className="text-green-600 focus:ring-green-500" />
<FaFileCsv className="text-green-600 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">CSV</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Comma-separated values</div>
</div>
</label>
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'excel' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="excel" checked={format === 'excel'} onChange={() => setFormat('excel')} className="text-green-600 focus:ring-green-500" />
<FaFileExcel className="text-green-700 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">Excel</div>
<div className="text-xs text-gray-500 dark:text-gray-400">XLSX spreadsheet</div>
</div>
</label>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Columns to Export</h4>
<div className="flex gap-2">
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">Select All</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button onClick={selectDefaultColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">Reset to Default</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
{exportColumns.map((col) => (
<label key={col.key} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${selectedColumns.includes(col.key) ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'}`}>
<input type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => toggleColumn(col.key)} className="rounded text-green-600 focus:ring-green-500" />
<span className="text-sm truncate">{col.label}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{selectedColumns.length} column{selectedColumns.length !== 1 ? 's' : ''} selected</p>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{scope === 'selected' && `Exporting ${selectedCount} selected row${selectedCount !== 1 ? 's' : ''}`}
{scope === 'all_on_page' && `Exporting ${pageCount} row${pageCount !== 1 ? 's' : ''} from current page`}
{scope === 'all_with_filters' && `Exporting all ${totalCount} row${totalCount !== 1 ? 's' : ''}`}
</div>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" disabled={isExporting}>Cancel</button>
<button onClick={() => onExport(scope, format, selectedColumns)} disabled={selectedColumns.length === 0 || isExporting} className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
{isExporting ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>Exporting...</>) : (<><FaDownload />Export</>)}
</button>
</div>
</div>
</div>
</div>
);
};
// Status badge colors
const getStatusStyle = (status: string) => {
switch (status?.toLowerCase()) {
case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'replied': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'on hold': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'resolved': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'closed': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
// Priority badge colors
const getPriorityStyle = (priority: string) => {
switch (priority?.toLowerCase()) {
case 'high': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
case 'medium': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'low': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
const IssueList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const EXPORT_COLUMNS = [
{ key: 'name', label: t('issues.issueId'), default: true },
{ key: 'subject', label: t('issues.subject'), default: true },
{ key: 'status', label: t('commonFields.status'), default: true },
{ key: 'priority', label: t('commonFields.priority'), default: true },
{ key: 'raised_by', label: t('issues.raisedBy'), default: true },
{ key: 'company', label: t('commonFields.company'), default: true },
{ key: 'contact', label: t('issues.contact'), default: false },
{ key: 'issue_type', label: t('issues.issueType'), default: false },
{ key: 'opening_date', label: t('issues.openingDate'), default: true },
{ key: 'resolution_date', label: t('issues.resolutionDate'), default: false },
{ key: 'resolution_by', label: t('issues.resolvedBy'), default: false },
{ key: 'first_responded_on', label: t('issues.firstRespondedOn'), default: false },
{ key: 'description', label: t('commonFields.description'), default: false },
{ key: 'resolution_details', label: t('issues.resolutionDetails'), default: false },
{ key: 'creation', label: t('commonFields.createdOn'), default: false },
{ key: 'modified', label: t('commonFields.modifiedOn'), default: false },
{ key: 'owner', label: t('commonFields.createdBy'), default: false },
];
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('');
const [priorityFilter, setPriorityFilter] = useState<string>('');
const [companyFilter, setCompanyFilter] = useState<string>('');
const [issueIdFilter, setIssueIdFilter] = useState<string>('');
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
const [filterPresetName, setFilterPresetName] = useState('');
useEffect(() => {
const saved = localStorage.getItem('issueFilterPresets');
if (saved) setSavedFilters(JSON.parse(saved));
}, []);
useEffect(() => {
const count = [statusFilter, priorityFilter, companyFilter, issueIdFilter].filter(Boolean).length;
setActiveFilterCount(count);
}, [statusFilter, priorityFilter, companyFilter, issueIdFilter]);
const apiFilters = useMemo(() => {
const filters: Record<string, any> = {};
if (statusFilter) filters['status'] = statusFilter;
if (priorityFilter) filters['priority'] = priorityFilter;
if (companyFilter) filters['company'] = companyFilter;
if (issueIdFilter) filters['name'] = issueIdFilter;
return filters;
}, [statusFilter, priorityFilter, companyFilter, issueIdFilter]);
const { issues, loading, error, totalCount, refetch } = useIssueList({
filters: apiFilters,
limit_start: (currentPage - 1) * pageSize,
limit_page_length: pageSize,
order_by: 'creation desc',
});
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
useEffect(() => { if (currentPage !== 1) setCurrentPage(1); }, [statusFilter, priorityFilter, companyFilter, issueIdFilter]);
useEffect(() => { setSelectedRows(new Set()); }, [statusFilter, priorityFilter, companyFilter, issueIdFilter, currentPage]);
const totalPages = Math.ceil(totalCount / pageSize);
const formatDate = (dateStr: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
const clearFilters = () => { setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter(''); setCurrentPage(1); };
const hasActiveFilters = statusFilter || priorityFilter || companyFilter || issueIdFilter;
const handleSaveFilterPreset = () => {
if (!filterPresetName.trim()) { alert('Please enter a filter name'); return; }
const preset = { id: Date.now(), name: filterPresetName, filters: { statusFilter, priorityFilter, companyFilter, issueIdFilter } };
const updated = [...savedFilters, preset];
setSavedFilters(updated);
setFilterPresetName('');
setShowSaveFilterModal(false);
localStorage.setItem('issueFilterPresets', JSON.stringify(updated));
};
const handleLoadFilterPreset = (preset: any) => {
const f = preset.filters;
setStatusFilter(f.statusFilter || ''); setPriorityFilter(f.priorityFilter || '');
setCompanyFilter(f.companyFilter || ''); setIssueIdFilter(f.issueIdFilter || '');
};
const handleDeleteFilterPreset = (id: number) => {
const updated = savedFilters.filter(f => f.id !== id);
setSavedFilters(updated);
localStorage.setItem('issueFilterPresets', JSON.stringify(updated));
};
const handleSelectRow = (issueName: string) => {
setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(issueName) ? newSet.delete(issueName) : newSet.add(issueName); return newSet; });
};
const handleSelectAll = () => { selectedRows.size === issues.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(issues.map(i => i.name))); };
const isAllSelected = issues.length > 0 && selectedRows.size === issues.length;
const isSomeSelected = selectedRows.size > 0 && selectedRows.size < issues.length;
const fetchAllIssuesForExport = useCallback(async (): Promise<any[]> => {
const allIssues: any[] = [];
let currentPageNum = 0;
const pageSizeNum = 100;
let hasMoreData = true;
while (hasMoreData) {
try {
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Issue', filters: apiFilters, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: 'creation desc' })
});
const data = await response.json();
const results = data.message || [];
allIssues.push(...results);
if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++;
if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; }
} catch (error) { console.error('Error fetching issues for export:', error); throw error; }
}
return allIssues;
}, [apiFilters]);
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
setIsExporting(true);
try {
let dataToExport: any[] = [];
switch (scope) {
case 'selected': dataToExport = issues.filter(i => selectedRows.has(i.name)); break;
case 'all_on_page': dataToExport = issues; break;
case 'all_with_filters': dataToExport = await fetchAllIssuesForExport(); break;
}
if (dataToExport.length === 0) { alert('No data to export'); return; }
const columnLabels = columns.map(key => EXPORT_COLUMNS.find(c => c.key === key)?.label || key);
if (format === 'csv') {
const csvContent = [columnLabels.join(','), ...dataToExport.map(issue => columns.map(key => { let value = issue[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) value = `"${value.replace(/"/g, '""')}"`; return value; }).join(','))].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = `issues_export_${new Date().toISOString().split('T')[0]}.csv`; link.click();
URL.revokeObjectURL(url);
} else if (format === 'excel') {
const worksheetData = [columnLabels, ...dataToExport.map(issue => columns.map(key => issue[key] || ''))];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Issues');
XLSX.writeFile(workbook, `issues_export_${new Date().toISOString().split('T')[0]}.xlsx`);
}
setShowExportModal(false); setSelectedRows(new Set());
} catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); }
finally { setIsExporting(false); }
};
const handleDelete = async (issueName: string) => {
try {
const response = await fetch(`/api/resource/Issue/${issueName}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } });
if (!response.ok) throw new Error('Failed to delete');
setDeleteConfirmOpen(null); refetch(); alert('Issue deleted successfully!');
} catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); }
};
if (loading && !initialLoadComplete) {
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">Loading issues...</p>
</div>
</div>
);
}
if (error) {
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-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Issues</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button onClick={refetch} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">Try Again</button>
</div>
</div>
);
}
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<div className="flex items-center gap-3">
<FaHeadset className="text-3xl text-blue-600 dark:text-blue-400" />
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Support Issues</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Total: {totalCount} issue{totalCount !== 1 ? 's' : ''}
{selectedRows.size > 0 && <span className="ml-2 text-blue-600 dark:text-blue-400"> {selectedRows.size} selected</span>}
{loading && initialLoadComplete && <span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>Updating...</span>}
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
<FaFilter />Filters
{activeFilterCount > 0 && <span className="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
</button>
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
<FaSync className={loading ? 'animate-spin' : ''} />Refresh
</button>
<button onClick={() => setShowExportModal(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all" disabled={totalCount === 0}>
<FaFileExport /><span className="font-medium">Export</span>
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
</button>
<button onClick={() => navigate('/support/new')} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
<FaPlus /><span className="font-medium">New Issue</span>
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Total Issues</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div><FaExclamationCircle className="text-3xl text-blue-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Open</p><p className="text-2xl font-bold text-blue-600">{issues.filter(i => i.status === 'Open').length}</p></div><FaClock className="text-3xl text-blue-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Resolved</p><p className="text-2xl font-bold text-green-600">{issues.filter(i => i.status === 'Resolved').length}</p></div><FaCheckCircle className="text-3xl text-green-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Closed</p><p className="text-2xl font-bold text-gray-600 dark:text-gray-300">{issues.filter(i => i.status === 'Closed').length}</p></div><FaTimesCircle className="text-3xl text-gray-500" /></div>
</div>
</div>
{/* Expandable Filter Panel */}
{isFilterExpanded && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3 rounded-t-lg">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">Filters</h3>
{activeFilterCount > 0 && <span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
</div>
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
<div className="flex items-center gap-2 py-1">
{issueIdFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Issue:</span> {issueIdFilter}<button onClick={() => setIssueIdFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{statusFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Status:</span> {statusFilter}<button onClick={() => setStatusFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{priorityFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-orange-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Priority:</span> {priorityFilter}<button onClick={() => setPriorityFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{companyFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-purple-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Company:</span> {companyFilter}<button onClick={() => setCompanyFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
</div>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{activeFilterCount > 0 && <button onClick={() => setShowSaveFilterModal(true)} className="px-3 py-1.5 bg-white text-blue-600 hover:bg-blue-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaSave size={12} /><span className="hidden sm:inline">Save</span></button>}
{hasActiveFilters && <button onClick={clearFilters} className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaTimes size={12} /><span className="hidden sm:inline">Clear</span></button>}
</div>
</div>
</div>
<div className="p-4">
{savedFilters.length > 0 && (
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2"><FaStar className="text-yellow-500" size={12} />Saved Filters</h4>
<div className="flex flex-wrap gap-2">
{savedFilters.map((preset) => (
<div key={preset.id} className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-purple-100 to-blue-100 dark:from-purple-900/30 dark:to-blue-900/30 border border-purple-200 dark:border-purple-700 rounded-lg hover:shadow-md transition-all">
<button onClick={() => handleLoadFilterPreset(preset)} className="text-xs font-medium text-purple-700 dark:text-purple-300">{preset.name}</button>
<button onClick={() => handleDeleteFilterPreset(preset.id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"><FaTrash size={10} /></button>
</div>
))}
</div>
</div>
)}
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="relative z-[60]">
<LinkField label="Issue" doctype="Issue" value={issueIdFilter} onChange={(val) => { setIssueIdFilter(val); setCurrentPage(1); }} placeholder="Select Issue" disabled={false} compact={true} />
{issueIdFilter && <button onClick={() => setIssueIdFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">Status</label>
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">All Statuses</option><option value="Open">Open</option><option value="Replied">Replied</option><option value="On Hold">On Hold</option><option value="Resolved">Resolved</option><option value="Closed">Closed</option>
</select>
</div>
<div className="relative z-[59]">
<LinkField label={t('commonFields.priority')} doctype="Issue Priority" value={priorityFilter} onChange={(val) => { setPriorityFilter(val); setCurrentPage(1); }} placeholder={t('issues.allPriorities')} disabled={false} compact={true} />
{priorityFilter && <button onClick={() => setPriorityFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative z-[58]">
<LinkField label={t('commonFields.company')} doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); setCurrentPage(1); }} placeholder={t('issues.allCompanies')} disabled={false} compact={true} />
{companyFilter && <button onClick={() => setCompanyFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
</div>
</div>
</div>
</div>
)}
{/* Save Filter Modal */}
{showSaveFilterModal && (
<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-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Save Filter Preset</h3>
<input type="text" value={filterPresetName} onChange={(e) => setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder="Enter filter name (e.g., 'Open High Priority')" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
<div className="flex gap-2 justify-end">
<button onClick={() => { setShowSaveFilterModal(false); setFilterPresetName(''); }} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">Cancel</button>
<button onClick={handleSaveFilterPreset} className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center gap-2"><FaSave size={12} />Save Filter</button>
</div>
</div>
</div>
)}
{/* Export Modal */}
<ExportModal isOpen={showExportModal} onClose={() => setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={issues.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
{loading && initialLoadComplete && (
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg"><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div><span className="text-sm text-gray-600 dark:text-gray-300">Filtering...</span></div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-4 py-3 text-left">
<button onClick={handleSelectAll} className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors" title={isAllSelected ? t('listPages.deselectAllTitle') : t('listPages.selectAllTitle')}>
{isAllSelected ? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} /> : isSomeSelected ? <div className="relative"><FaSquare size={18} /><div className="absolute inset-0 flex items-center justify-center"><div className="w-2 h-0.5 bg-current"></div></div></div> : <FaSquare size={18} />}
</button>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.issueId')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.subject')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.priority')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.company')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.openingDate')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('listPages.actions')}</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{issues.length === 0 ? (
<tr><td colSpan={8} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center"><FaHeadset className="text-4xl text-gray-300 dark:text-gray-600 mb-2" /><p>No issues found</p>
{hasActiveFilters ? <button onClick={clearFilters} className="mt-4 text-blue-600 dark:text-blue-400 hover:underline">Clear filters</button> : <button onClick={() => navigate('/support/new')} className="mt-4 text-blue-600 dark:text-blue-400 hover:underline">Create your first issue</button>}
</div>
</td></tr>
) : issues.map((issue) => (
<tr key={issue.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(issue.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`} onClick={() => navigate(`/support/${issue.name}`)}>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleSelectRow(issue.name)} className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
{selectedRows.has(issue.name) ? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} /> : <FaSquare size={18} />}
</button>
</td>
<td className="px-4 py-3"><span className="text-sm font-medium text-blue-600 dark:text-blue-400">{issue.name}</span></td>
<td className="px-4 py-3"><span className="text-sm text-gray-900 dark:text-white line-clamp-1">{issue.subject || '-'}</span></td>
<td className="px-4 py-3"><span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getStatusStyle(issue.status)}`}>{issue.status || '-'}</span></td>
<td className="px-4 py-3">{issue.priority ? <span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getPriorityStyle(issue.priority)}`}>{issue.priority}</span> : <span className="text-gray-400">-</span>}</td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300 line-clamp-1">{issue.company || '-'}</span></td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(issue.opening_date)}</span></td>
<td className="px-4 py-3">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => navigate(`/support/${issue.name}`)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title={t('issues.viewDetails')}><FaEye /></button>
<button onClick={() => navigate(`/support/${issue.name}`)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title={t('issues.editIssue')}><FaEdit /></button>
<button onClick={() => setDeleteConfirmOpen(issue.name)} className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors" title={t('issues.deleteIssue')}><FaTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalCount)} of {totalCount} issues</div>
<div className="flex items-center gap-2">
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"><FaChevronLeft /></button>
<span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"><FaChevronRight /></button>
</div>
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-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-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"><FaTrash className="text-red-600 dark:text-red-400 text-xl" /></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Delete Issue</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Are you sure you want to delete this issue? This action cannot be undone.</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4"><p className="text-xs text-yellow-800 dark:text-yellow-300"><strong>Issue ID:</strong> {deleteConfirmOpen}</p></div>
<div className="flex gap-3 justify-end">
<button onClick={() => setDeleteConfirmOpen(null)} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">Cancel</button>
<button onClick={() => handleDelete(deleteConfirmOpen)} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2"><FaTrash />Delete Issue</button>
</div>
</div>
</div>
</div>
</div>
)}
<style>{`
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.animate-scale-in { animation: scale-in 0.2s ease-out; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
`}</style>
</div>
);
};
export default IssueList;

View File

@ -0,0 +1,623 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMaintenanceTeamDetails, useMaintenanceTeamMutations } from '../hooks/useMaintenanceTeam';
import {
FaArrowLeft,
FaSave,
FaEdit,
FaTrash,
FaCheckCircle,
FaTimesCircle,
FaExclamationTriangle,
FaUsers,
FaUserTie,
FaBuilding,
FaPlus,
FaUserPlus,
FaTimes
} from 'react-icons/fa';
import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import LinkField from '../components/LinkField';
import type { CreateMaintenanceTeamData, MaintenanceTeamMember } from '../services/maintenanceTeamService';
const MaintenanceTeamDetail: React.FC = () => {
const { t } = useTranslation();
const { teamName } = useParams<{ teamName: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isNewTeam = teamName === 'new';
const duplicateFrom = searchParams.get('duplicate');
const [formData, setFormData] = useState<CreateMaintenanceTeamData>({
maintenance_team_name: '',
maintenance_manager: '',
maintenance_manager_name: '',
company: '',
custom_expertise: '',
maintenance_team_members: [],
});
const { team, loading, error, refetch } = useMaintenanceTeamDetails(
isNewTeam ? (duplicateFrom || null) : (teamName || null)
);
const { createTeam, updateTeam, deleteTeam, getUserFullName, loading: saving } = useMaintenanceTeamMutations();
const [isEditing, setIsEditing] = useState(isNewTeam);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showDeleteMemberConfirm, setShowDeleteMemberConfirm] = useState<number | null>(null);
const [checkingMember, setCheckingMember] = useState<number | null>(null); // Track which row is being checked
// Check if a team member exists in other teams
const checkMemberInOtherTeams = async (memberEmail: string): Promise<{ exists: boolean; teamName?: string }> => {
if (!memberEmail) return { exists: false };
try {
// Method 1: Try querying via run_doc_method or SQL
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
doctype: 'Asset Maintenance Team',
filters: {},
fields: ['name', 'maintenance_team_name', 'maintenance_team_members.team_member'],
limit_page_length: 0, // Get all
})
});
if (!response.ok) {
// Fallback: Query all teams and check manually
return await checkMemberInOtherTeamsFallback(memberEmail);
}
const data = await response.json();
const results = data.message || [];
// Check each team for the member
for (const teamData of results) {
// Skip current team
if (teamData.name === teamName || teamData.name === team?.name) continue;
if (teamData['maintenance_team_members.team_member'] === memberEmail) {
return { exists: true, teamName: teamData.maintenance_team_name || teamData.name };
}
}
// If the above doesn't work, use fallback
return await checkMemberInOtherTeamsFallback(memberEmail);
} catch (error) {
console.error('Error checking member in other teams:', error);
// Try fallback method
return await checkMemberInOtherTeamsFallback(memberEmail);
}
};
// Fallback method: Fetch all teams with their members
const checkMemberInOtherTeamsFallback = async (memberEmail: string): Promise<{ exists: boolean; teamName?: string }> => {
try {
// Get list of all maintenance teams
const listResponse = await fetch('/api/resource/Asset Maintenance Team?fields=["name","maintenance_team_name"]&limit_page_length=0', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
if (!listResponse.ok) return { exists: false };
const listData = await listResponse.json();
const teams = listData.data || [];
// Check each team's members
for (const teamInfo of teams) {
// Skip current team
if (teamInfo.name === teamName || teamInfo.name === team?.name) continue;
// Fetch full team details including members
const teamResponse = await fetch(`/api/resource/Asset Maintenance Team/${encodeURIComponent(teamInfo.name)}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
if (!teamResponse.ok) continue;
const teamData = await teamResponse.json();
const members = teamData.data?.maintenance_team_members || [];
// Check if member exists in this team
const memberExists = members.some((m: any) => m.team_member === memberEmail);
if (memberExists) {
return {
exists: true,
teamName: teamData.data?.maintenance_team_name || teamInfo.name
};
}
}
return { exists: false };
} catch (error) {
console.error('Fallback check failed:', error);
return { exists: false };
}
};
// Load team data when fetched
useEffect(() => {
if (team) {
setFormData({
maintenance_team_name: isNewTeam && duplicateFrom ? `${team.maintenance_team_name} (Copy)` : team.maintenance_team_name || '',
maintenance_manager: team.maintenance_manager || '',
maintenance_manager_name: team.maintenance_manager_name || '',
company: team.company || '',
custom_expertise: team.custom_expertise || '',
maintenance_team_members: team.maintenance_team_members?.map((m, idx) => ({
...m,
idx: idx + 1,
name: isNewTeam ? undefined : m.name, // Clear name for duplicates
})) || [],
});
if (!isNewTeam) setIsEditing(false);
}
}, [team, isNewTeam, duplicateFrom]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Handle manager change and fetch full name
const handleManagerChange = async (email: string) => {
setFormData(prev => ({ ...prev, maintenance_manager: email }));
if (email) {
const fullName = await getUserFullName(email);
setFormData(prev => ({ ...prev, maintenance_manager_name: fullName }));
} else {
setFormData(prev => ({ ...prev, maintenance_manager_name: '' }));
}
};
// Handle team member change
const handleMemberChange = async (index: number, field: string, value: string) => {
// If team_member changed, fetch full name and check for duplicates
if (field === 'team_member' && value) {
// Check if member is already in current team (other rows)
const existsInCurrentTeam = formData.maintenance_team_members?.some(
(m, i) => i !== index && m.team_member === value
);
if (existsInCurrentTeam) {
toast.error('This member is already added to this team!', {
position: "top-right",
autoClose: 4000,
icon: <FaTimesCircle />,
});
return; // Don't update if already in current team
}
// Show checking state
setCheckingMember(index);
toast.info('Checking member availability...', {
position: "top-right",
autoClose: 2000,
icon: () => <span>🔍</span>,
});
// Check if member exists in other teams
const { exists, teamName: otherTeamName } = await checkMemberInOtherTeams(value);
setCheckingMember(null);
if (exists) {
toast.error(
<div>
<strong>Cannot add member!</strong>
<br />
<span className="text-sm">This member is already assigned to: <b>{otherTeamName}</b></span>
</div>,
{
position: "top-right",
autoClose: 5000,
icon: <FaTimesCircle />,
}
);
return; // Don't update if already in another team
}
// Fetch full name
const fullName = await getUserFullName(value);
// Update the member data
const updatedMembers = [...(formData.maintenance_team_members || [])];
updatedMembers[index] = {
...updatedMembers[index],
team_member: value,
full_name: fullName
};
setFormData(prev => ({ ...prev, maintenance_team_members: updatedMembers }));
toast.success('Member added successfully!', {
position: "top-right",
autoClose: 2000,
icon: <FaCheckCircle />,
});
} else {
// For other fields (like role), just update directly
const updatedMembers = [...(formData.maintenance_team_members || [])];
updatedMembers[index] = { ...updatedMembers[index], [field]: value };
setFormData(prev => ({ ...prev, maintenance_team_members: updatedMembers }));
}
};
// Add new team member
const handleAddMember = () => {
const newMember: MaintenanceTeamMember = {
team_member: '',
full_name: '',
maintenance_role: '',
idx: (formData.maintenance_team_members?.length || 0) + 1,
};
setFormData(prev => ({
...prev,
maintenance_team_members: [...(prev.maintenance_team_members || []), newMember],
}));
};
// Remove team member
const handleRemoveMember = (index: number) => {
const updatedMembers = formData.maintenance_team_members?.filter((_, i) => i !== index) || [];
// Re-index
updatedMembers.forEach((m, i) => { m.idx = i + 1; });
setFormData(prev => ({ ...prev, maintenance_team_members: updatedMembers }));
setShowDeleteMemberConfirm(null);
};
const handleSave = async () => {
if (!formData.maintenance_team_name) {
toast.error('Please enter a team name', { position: "top-right", autoClose: 4000, icon: <FaTimesCircle /> });
return;
}
try {
// Clean up member data for submission
const cleanedData = {
...formData,
maintenance_team_members: formData.maintenance_team_members?.map(m => ({
team_member: m.team_member,
full_name: m.full_name,
maintenance_role: m.maintenance_role,
})).filter(m => m.team_member), // Only include members with team_member set
};
if (isNewTeam) {
const newTeam = await createTeam(cleanedData);
toast.success('Maintenance Team created successfully!', { position: "top-right", autoClose: 3000, icon: <FaCheckCircle /> });
navigate(`/maintenance-teams/${newTeam.name}`);
} else {
await updateTeam(teamName!, cleanedData);
toast.success('Maintenance Team updated successfully!', { position: "top-right", autoClose: 3000, icon: <FaCheckCircle /> });
setIsEditing(false);
refetch();
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to save: ${errorMessage}`, { position: "top-right", autoClose: 6000, icon: <FaTimesCircle /> });
}
};
const handleDelete = async () => {
try {
await deleteTeam(teamName!);
toast.success('Maintenance Team deleted successfully!', { position: "top-right", autoClose: 3000, icon: <FaCheckCircle /> });
navigate('/maintenance-teams');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to delete: ${errorMessage}`, { position: "top-right", autoClose: 6000, icon: <FaTimesCircle /> });
}
};
const isFieldDisabled = useCallback((fieldname: string): boolean => {
if (!isEditing) return true;
return false;
}, [isEditing]);
const formatDateTime = (dateStr?: string) => dateStr ? new Date(dateStr).toLocaleString() : '-';
if (loading) {
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-indigo-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading maintenance team...</p>
</div>
</div>
);
}
if (error && !isNewTeam) {
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-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">{t('maintenance.errorLoadingTeam')}</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button onClick={() => navigate('/maintenance-teams')} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded">{t('maintenance.backToTeams')}</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<ToastContainer position="top-right" autoClose={4000} hideProgressBar={false} newestOnTop closeOnClick rtl={false} pauseOnFocusLoss draggable pauseOnHover theme="colored" transition={Bounce} />
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button onClick={() => navigate('/maintenance-teams')} className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
<FaArrowLeft size={20} />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white flex items-center gap-3">
<FaUsers className="text-indigo-500" />
{isNewTeam ? 'New Maintenance Team' : team?.maintenance_team_name || 'Maintenance Team'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isNewTeam ? 'Create a new maintenance team' : team?.name}
</p>
</div>
</div>
<div className="flex gap-3">
{!isNewTeam && !isEditing && (
<>
<button onClick={() => setIsEditing(true)} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg flex items-center gap-2">
<FaEdit />{t('common.edit')}
</button>
<button onClick={() => setShowDeleteConfirm(true)} className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2">
<FaTrash />{t('common.delete')}
</button>
</>
)}
{isEditing && (
<>
<button onClick={() => { if (isNewTeam) navigate('/maintenance-teams'); else { setIsEditing(false); refetch(); } }} className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg">
{t('common.cancel')}
</button>
<button onClick={handleSave} disabled={saving} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50">
<FaSave />{saving ? t('common.saving') : t('common.save')}
</button>
</>
)}
</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('maintenance.deleteTeam')}</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">{t('confirmations.cannotUndo')}</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">{saving ? t('common.deleting') : t('common.delete')}</button>
</div>
</div>
</div>
)}
{/* Form */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Team 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-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaUsers className="text-indigo-500" />{t('maintenance.teamInformation')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('maintenance.teamName')} <span className="text-red-500">*</span>
</label>
<input type="text" name="maintenance_team_name" value={formData.maintenance_team_name} onChange={handleChange} disabled={isFieldDisabled('maintenance_team_name')} placeholder={t('maintenance.enterTeamName')} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<div>
<LinkField label={t('commonFields.hospital')} doctype="Company" value={formData.company || ''} onChange={(val) => setFormData({ ...formData, company: val })} disabled={isFieldDisabled('company')} placeholder={t('maintenance.selectHospital')} />
</div>
<div>
<LinkField label={t('maintenance.expertise')} doctype="Issue Type" value={formData.custom_expertise || ''} onChange={(val) => setFormData({ ...formData, custom_expertise: val })} disabled={isFieldDisabled('custom_expertise')} placeholder={t('maintenance.selectExpertise')} />
</div>
<div>
<LinkField label={t('maintenance.manager')} doctype="User" value={formData.maintenance_manager || ''} onChange={handleManagerChange} disabled={isFieldDisabled('maintenance_manager')} placeholder={t('maintenance.selectManager')} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('maintenance.managerName')}</label>
<input type="text" value={formData.maintenance_manager_name || ''} disabled className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400" />
</div>
</div>
</div>
{/* Team Members */}
<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-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<FaUserPlus className="text-green-500" />Team Members
</h2>
{isEditing && (
<button onClick={handleAddMember} disabled={checkingMember !== null} className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-lg flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
<FaPlus size={12} />Add Member
</button>
)}
</div>
{formData.maintenance_team_members && formData.maintenance_team_members.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">#</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Team Member<span className="text-red-500">*</span></th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Full Name</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role<span className="text-red-500">*</span></th>
{isEditing && <th className="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Action</th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{formData.maintenance_team_members.map((member, index) => (
<tr key={index} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${checkingMember === index ? 'opacity-70' : ''}`}>
<td className="px-3 py-2 text-sm text-gray-600 dark:text-gray-400">{index + 1}</td>
<td className="px-3 py-2">
{isEditing ? (
<div className="relative">
<LinkField label="" doctype="User" value={member.team_member || ''} onChange={(val) => handleMemberChange(index, 'team_member', val)} disabled={checkingMember !== null} placeholder={t('maintenance.selectUser')} compact={true} />
{checkingMember === index && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-500"></div>
</div>
)}
</div>
) : (
<span className="text-sm text-gray-900 dark:text-white">{member.team_member || '-'}</span>
)}
</td>
<td className="px-3 py-2 text-sm text-gray-600 dark:text-gray-300">{member.full_name || '-'}</td>
<td className="px-3 py-2">
{isEditing ? (
<LinkField label="" doctype="Role" value={member.maintenance_role || ''} onChange={(val) => handleMemberChange(index, 'maintenance_role', val)} disabled={checkingMember !== null} placeholder={t('maintenance.selectRole')} compact={true} />
) : (
<span className="text-sm text-gray-600 dark:text-gray-300">{member.maintenance_role || '-'}</span>
)}
</td>
{isEditing && (
<td className="px-3 py-2 text-center">
<button onClick={() => setShowDeleteMemberConfirm(index)} className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 p-1.5 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors" title={t('maintenance.removeMember')}>
<FaTrash size={14} />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<FaUsers className="text-4xl mx-auto mb-2 text-gray-300 dark:text-gray-600" />
<p>{t('maintenance.noTeamMembersYet')}</p>
{isEditing && (
<button onClick={handleAddMember} className="mt-3 text-indigo-600 dark:text-indigo-400 hover:underline">
+ {t('maintenance.addFirstMember')}
</button>
)}
</div>
)}
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Team 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-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaUserTie className="text-blue-500" />{t('maintenance.teamSummary')}
</h2>
<div className="space-y-4">
<div className="p-4 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg border border-indigo-200 dark:border-indigo-800">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('maintenance.totalMembers')}</p>
<p className="text-2xl font-bold text-indigo-600 dark:text-indigo-300">
{formData.maintenance_team_members?.filter(m => m.team_member).length || 0}
</p>
</div>
{formData.maintenance_manager_name && (
<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('maintenance.manager')}</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{formData.maintenance_manager_name}</p>
</div>
)}
{formData.company && (
<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">Hospital</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{formData.company}</p>
</div>
)}
{formData.custom_expertise && (
<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">Expertise</p>
<span className="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300">
{formData.custom_expertise}
</span>
</div>
)}
</div>
</div>
{/* Timeline Card */}
{!isNewTeam && team && (
<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-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaBuilding className="text-teal-500" />Details
</h2>
<div className="space-y-4">
<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">Created</p>
<p className="text-sm text-gray-900 dark:text-white">{formatDateTime(team.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">Last Modified</p>
<p className="text-sm text-gray-900 dark:text-white">{formatDateTime(team.modified)}</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">Modified By</p>
<p className="text-sm text-gray-900 dark:text-white">{team.modified_by || '-'}</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* Delete Member Confirmation Modal */}
{showDeleteMemberConfirm !== null && (
<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-sm w-full mx-4 shadow-xl">
<div className="flex items-start gap-3 mb-4">
<FaExclamationTriangle className="text-orange-500 text-xl mt-0.5" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">Remove Team Member</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">Are you sure you want to remove this team member?</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button onClick={() => setShowDeleteMemberConfirm(null)} className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg">Cancel</button>
<button onClick={() => handleRemoveMember(showDeleteMemberConfirm)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">Remove</button>
</div>
</div>
</div>
)}
</div>
);
};
export default MaintenanceTeamDetail;

View File

@ -0,0 +1,570 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMaintenanceTeamList } from '../hooks/useMaintenanceTeam';
import * as XLSX from 'xlsx';
import {
FaPlus,
FaFilter,
FaSync,
FaEye,
FaChevronLeft,
FaChevronRight,
FaTimes,
FaSave,
FaStar,
FaTrash,
FaEdit,
FaCheckSquare,
FaSquare,
FaFileExport,
FaFileExcel,
FaFileCsv,
FaDownload,
FaUsers,
FaUserTie,
FaBuilding,
FaCopy
} from 'react-icons/fa';
import LinkField from '../components/LinkField';
// Export types
type ExportFormat = 'csv' | 'excel';
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
selectedCount: number;
totalCount: number;
pageCount: number;
onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void;
isExporting: boolean;
exportColumns: Array<{key: string, label: string, default: boolean}>;
}
const ExportModal: React.FC<ExportModalProps> = ({
isOpen, onClose, selectedCount, totalCount, pageCount, onExport, isExporting, exportColumns
}) => {
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
const [format, setFormat] = useState<ExportFormat>('csv');
const [selectedColumns, setSelectedColumns] = useState<string[]>(exportColumns.filter(c => c.default).map(c => c.key));
useEffect(() => { setScope(selectedCount > 0 ? 'selected' : 'all_with_filters'); }, [selectedCount]);
const toggleColumn = (key: string) => setSelectedColumns(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]);
const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key));
const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key));
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FaFileExport className="text-white text-xl" />
<h3 className="text-lg font-semibold text-white">Export Maintenance Teams</h3>
</div>
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
<FaTimes size={20} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Select Data to Export</h4>
<div className="space-y-2">
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'selected' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
<input type="radio" name="scope" value="selected" checked={scope === 'selected'} onChange={() => setScope('selected')} disabled={selectedCount === 0} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">Selected Rows</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Export {selectedCount} selected team{selectedCount !== 1 ? 's' : ''}</div>
</div>
{selectedCount > 0 && <span className="bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 px-2 py-1 rounded text-xs font-medium">{selectedCount} selected</span>}
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_on_page' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_on_page" checked={scope === 'all_on_page'} onChange={() => setScope('all_on_page')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">Current Page</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Export {pageCount} team{pageCount !== 1 ? 's' : ''} on current page</div>
</div>
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">{pageCount} rows</span>
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_with_filters' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_with_filters" checked={scope === 'all_with_filters'} onChange={() => setScope('all_with_filters')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">All Records (with current filters)</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Export all {totalCount} team{totalCount !== 1 ? 's' : ''} matching current filters</div>
</div>
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">{totalCount} total</span>
</label>
</div>
</div>
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Export Format</h4>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'csv' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="csv" checked={format === 'csv'} onChange={() => setFormat('csv')} className="text-green-600 focus:ring-green-500" />
<FaFileCsv className="text-green-600 text-xl" />
<div><div className="font-medium text-gray-900 dark:text-white">CSV</div><div className="text-xs text-gray-500 dark:text-gray-400">Comma-separated values</div></div>
</label>
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'excel' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="excel" checked={format === 'excel'} onChange={() => setFormat('excel')} className="text-green-600 focus:ring-green-500" />
<FaFileExcel className="text-green-700 text-xl" />
<div><div className="font-medium text-gray-900 dark:text-white">Excel</div><div className="text-xs text-gray-500 dark:text-gray-400">XLSX spreadsheet</div></div>
</label>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Columns to Export</h4>
<div className="flex gap-2">
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">Select All</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button onClick={selectDefaultColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">Reset to Default</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
{exportColumns.map((col) => (
<label key={col.key} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${selectedColumns.includes(col.key) ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'}`}>
<input type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => toggleColumn(col.key)} className="rounded text-green-600 focus:ring-green-500" />
<span className="text-sm truncate">{col.label}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{selectedColumns.length} column{selectedColumns.length !== 1 ? 's' : ''} selected</p>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{scope === 'selected' && `Exporting ${selectedCount} selected row${selectedCount !== 1 ? 's' : ''}`}
{scope === 'all_on_page' && `Exporting ${pageCount} row${pageCount !== 1 ? 's' : ''} from current page`}
{scope === 'all_with_filters' && `Exporting all ${totalCount} row${totalCount !== 1 ? 's' : ''}`}
</div>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" disabled={isExporting}>Cancel</button>
<button onClick={() => onExport(scope, format, selectedColumns)} disabled={selectedColumns.length === 0 || isExporting} className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
{isExporting ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>Exporting...</>) : (<><FaDownload />Export</>)}
</button>
</div>
</div>
</div>
</div>
);
};
const MaintenanceTeamList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const EXPORT_COLUMNS = [
{ key: 'name', label: t('maintenance.teamId'), default: true },
{ key: 'maintenance_team_name', label: t('maintenance.teamName'), default: true },
{ key: 'maintenance_manager', label: t('maintenance.managerEmail'), default: true },
{ key: 'maintenance_manager_name', label: t('maintenance.managerName'), default: true },
{ key: 'company', label: t('commonFields.hospital'), default: true },
{ key: 'custom_expertise', label: t('maintenance.expertise'), default: true },
{ key: 'creation', label: t('commonFields.createdOn'), default: false },
{ key: 'modified', label: t('commonFields.modifiedOn'), default: false },
];
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [companyFilter, setCompanyFilter] = useState<string>('');
const [teamNameFilter, setTeamNameFilter] = useState<string>('');
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
const [filterPresetName, setFilterPresetName] = useState('');
useEffect(() => {
const saved = localStorage.getItem('maintenanceTeamFilterPresets');
if (saved) setSavedFilters(JSON.parse(saved));
}, []);
useEffect(() => {
const count = [companyFilter, teamNameFilter].filter(Boolean).length;
setActiveFilterCount(count);
}, [companyFilter, teamNameFilter]);
const apiFilters = useMemo(() => {
const filters: Record<string, any> = {};
if (companyFilter) filters['company'] = companyFilter;
if (teamNameFilter) filters['name'] = teamNameFilter;
return filters;
}, [companyFilter, teamNameFilter]);
const { teams, loading, error, totalCount, refetch } = useMaintenanceTeamList({
filters: apiFilters,
limit_start: (currentPage - 1) * pageSize,
limit_page_length: pageSize,
order_by: 'creation desc',
});
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
useEffect(() => { if (currentPage !== 1) setCurrentPage(1); }, [companyFilter, teamNameFilter]);
useEffect(() => { setSelectedRows(new Set()); }, [companyFilter, teamNameFilter, currentPage]);
const totalPages = Math.ceil(totalCount / pageSize);
const formatDate = (dateStr?: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
const clearFilters = () => { setCompanyFilter(''); setTeamNameFilter(''); setCurrentPage(1); };
const hasActiveFilters = companyFilter || teamNameFilter;
const handleSaveFilterPreset = () => {
if (!filterPresetName.trim()) { alert('Please enter a filter name'); return; }
const preset = { id: Date.now(), name: filterPresetName, filters: { companyFilter, teamNameFilter } };
const updated = [...savedFilters, preset];
setSavedFilters(updated);
setFilterPresetName('');
setShowSaveFilterModal(false);
localStorage.setItem('maintenanceTeamFilterPresets', JSON.stringify(updated));
};
const handleLoadFilterPreset = (preset: any) => {
const f = preset.filters;
setCompanyFilter(f.companyFilter || '');
setTeamNameFilter(f.teamNameFilter || '');
};
const handleDeleteFilterPreset = (id: number) => {
const updated = savedFilters.filter(f => f.id !== id);
setSavedFilters(updated);
localStorage.setItem('maintenanceTeamFilterPresets', JSON.stringify(updated));
};
const handleSelectRow = (teamName: string) => {
setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(teamName) ? newSet.delete(teamName) : newSet.add(teamName); return newSet; });
};
const handleSelectAll = () => { selectedRows.size === teams.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(teams.map(t => t.name))); };
const isAllSelected = teams.length > 0 && selectedRows.size === teams.length;
const isSomeSelected = selectedRows.size > 0 && selectedRows.size < teams.length;
const fetchAllTeamsForExport = useCallback(async (): Promise<any[]> => {
const allTeams: any[] = [];
let currentPageNum = 0;
const pageSizeNum = 100;
let hasMoreData = true;
while (hasMoreData) {
try {
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Asset Maintenance Team', filters: apiFilters, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: 'creation desc' })
});
const data = await response.json();
const results = data.message || [];
allTeams.push(...results);
if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++;
if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; }
} catch (error) { console.error('Error fetching teams for export:', error); throw error; }
}
return allTeams;
}, [apiFilters]);
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
setIsExporting(true);
try {
let dataToExport: any[] = [];
switch (scope) {
case 'selected': dataToExport = teams.filter(t => selectedRows.has(t.name)); break;
case 'all_on_page': dataToExport = teams; break;
case 'all_with_filters': dataToExport = await fetchAllTeamsForExport(); break;
}
if (dataToExport.length === 0) { alert('No data to export'); return; }
const columnLabels = columns.map(key => EXPORT_COLUMNS.find(c => c.key === key)?.label || key);
if (format === 'csv') {
const csvContent = [columnLabels.join(','), ...dataToExport.map(team => columns.map(key => { let value = team[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) value = `"${value.replace(/"/g, '""')}"`; return value; }).join(','))].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = `maintenance_teams_export_${new Date().toISOString().split('T')[0]}.csv`; link.click();
URL.revokeObjectURL(url);
} else if (format === 'excel') {
const worksheetData = [columnLabels, ...dataToExport.map(team => columns.map(key => team[key] || ''))];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Maintenance Teams');
XLSX.writeFile(workbook, `maintenance_teams_export_${new Date().toISOString().split('T')[0]}.xlsx`);
}
setShowExportModal(false); setSelectedRows(new Set());
} catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); }
finally { setIsExporting(false); }
};
const handleDelete = async (teamName: string) => {
try {
const response = await fetch(`/api/resource/Asset Maintenance Team/${encodeURIComponent(teamName)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } });
if (!response.ok) throw new Error('Failed to delete');
setDeleteConfirmOpen(null); refetch(); alert('Maintenance Team deleted successfully!');
} catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); }
};
if (loading && !initialLoadComplete) {
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">Loading maintenance teams...</p>
</div>
</div>
);
}
if (error) {
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-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Maintenance Teams</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button onClick={refetch} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">Try Again</button>
</div>
</div>
);
}
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<div className="flex items-center gap-3">
<FaUsers className="text-3xl text-indigo-600 dark:text-indigo-400" />
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Maintenance Teams</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Total: {totalCount} team{totalCount !== 1 ? 's' : ''}
{selectedRows.size > 0 && <span className="ml-2 text-blue-600 dark:text-blue-400"> {selectedRows.size} selected</span>}
{loading && initialLoadComplete && <span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>Updating...</span>}
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
<FaFilter />Filters
{activeFilterCount > 0 && <span className="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
</button>
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
<FaSync className={loading ? 'animate-spin' : ''} />Refresh
</button>
<button onClick={() => setShowExportModal(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all" disabled={totalCount === 0}>
<FaFileExport /><span className="font-medium">Export</span>
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
</button>
<button onClick={() => navigate('/maintenance-teams/new')} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
<FaPlus /><span className="font-medium">New Team</span>
</button>
</div>
</div>
{/* Stats Cards */}
{/* <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Total Teams</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div><FaUsers className="text-3xl text-indigo-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Managers</p><p className="text-2xl font-bold text-blue-600">{new Set(teams.map(t => t.maintenance_manager).filter(Boolean)).size}</p></div><FaUserTie className="text-3xl text-blue-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Hospitals</p><p className="text-2xl font-bold text-green-600">{new Set(teams.map(t => t.company).filter(Boolean)).size}</p></div><FaBuilding className="text-3xl text-green-500" /></div>
</div>
</div> */}
{/* Expandable Filter Panel */}
{isFilterExpanded && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
<div className="bg-gradient-to-r from-indigo-500 to-indigo-600 dark:from-indigo-600 dark:to-indigo-700 px-4 py-3 rounded-t-lg">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">Filters</h3>
{activeFilterCount > 0 && <span className="bg-white text-indigo-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
</div>
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
<div className="flex items-center gap-2 py-1">
{companyFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Hospital:</span> {companyFilter}<button onClick={() => setCompanyFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{teamNameFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Team:</span> {teamNameFilter}<button onClick={() => setTeamNameFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
</div>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{activeFilterCount > 0 && <button onClick={() => setShowSaveFilterModal(true)} className="px-3 py-1.5 bg-white text-indigo-600 hover:bg-indigo-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaSave size={12} /><span className="hidden sm:inline">Save</span></button>}
{hasActiveFilters && <button onClick={clearFilters} className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaTimes size={12} /><span className="hidden sm:inline">Clear</span></button>}
</div>
</div>
</div>
<div className="p-4">
{savedFilters.length > 0 && (
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2"><FaStar className="text-yellow-500" size={12} />Saved Filters</h4>
<div className="flex flex-wrap gap-2">
{savedFilters.map((preset) => (
<div key={preset.id} className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-purple-100 to-indigo-100 dark:from-purple-900/30 dark:to-indigo-900/30 border border-purple-200 dark:border-purple-700 rounded-lg hover:shadow-md transition-all">
<button onClick={() => handleLoadFilterPreset(preset)} className="text-xs font-medium text-purple-700 dark:text-purple-300">{preset.name}</button>
<button onClick={() => handleDeleteFilterPreset(preset.id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"><FaTrash size={10} /></button>
</div>
))}
</div>
</div>
)}
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="relative z-[60]">
<LinkField label="Hospital" doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); setCurrentPage(1); }} placeholder="Select Hospital" disabled={false} compact={true} />
{companyFilter && <button onClick={() => setCompanyFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative z-[59]">
<LinkField label={t('maintenance.teamName')} doctype="Asset Maintenance Team" value={teamNameFilter} onChange={(val) => { setTeamNameFilter(val); setCurrentPage(1); }} placeholder={t('maintenance.selectTeam')} disabled={false} compact={true} />
{teamNameFilter && <button onClick={() => setTeamNameFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
</div>
</div>
</div>
</div>
)}
{/* Save Filter Modal */}
{showSaveFilterModal && (
<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-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{t('listPages.saveFilterPreset')}</h3>
<input type="text" value={filterPresetName} onChange={(e) => setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder={t('listPages.enterFilterName')} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
<div className="flex gap-2 justify-end">
<button onClick={() => { setShowSaveFilterModal(false); setFilterPresetName(''); }} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">{t('common.cancel')}</button>
<button onClick={handleSaveFilterPreset} className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md transition-colors flex items-center gap-2"><FaSave size={12} />{t('listPages.saveFilter')}</button>
</div>
</div>
</div>
)}
{/* Export Modal */}
<ExportModal isOpen={showExportModal} onClose={() => setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={teams.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
{loading && initialLoadComplete && (
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg"><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-indigo-500"></div><span className="text-sm text-gray-600 dark:text-gray-300">{t('listPages.filtering')}</span></div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-4 py-3 text-left">
<button onClick={handleSelectAll} className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" title={isAllSelected ? t('listPages.deselectAllTitle') : t('listPages.selectAllTitle')}>
{isAllSelected ? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} /> : isSomeSelected ? <div className="relative"><FaSquare size={18} /><div className="absolute inset-0 flex items-center justify-center"><div className="w-2 h-0.5 bg-current"></div></div></div> : <FaSquare size={18} />}
</button>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('maintenance.teamName')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('maintenance.managerName')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.hospital')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('maintenance.expertise')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.createdOn')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('listPages.actions')}</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{teams.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center"><FaUsers className="text-4xl text-gray-300 dark:text-gray-600 mb-2" /><p>{t('listPages.noMaintenanceTeamsFound')}</p>
{hasActiveFilters ? <button onClick={clearFilters} className="mt-4 text-indigo-600 dark:text-indigo-400 hover:underline">{t('listPages.clearFilters')}</button> : <button onClick={() => navigate('/maintenance-teams/new')} className="mt-4 text-indigo-600 dark:text-indigo-400 hover:underline">{t('listPages.createFirstTeam')}</button>}
</div>
</td></tr>
) : teams.map((team) => (
<tr key={team.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(team.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`} onClick={() => navigate(`/maintenance-teams/${team.name}`)}>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleSelectRow(team.name)} className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">
{selectedRows.has(team.name) ? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} /> : <FaSquare size={18} />}
</button>
</td>
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{team.maintenance_team_name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{team.name}</div>
</td>
<td className="px-4 py-3">
<div className="text-sm text-gray-900 dark:text-white">{team.maintenance_manager_name || '-'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{team.maintenance_manager || '-'}</div>
</td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{team.company || '-'}</span></td>
<td className="px-4 py-3">
{team.custom_expertise ? (
<span className="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300">{team.custom_expertise}</span>
) : <span className="text-gray-400">-</span>}
</td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(team.creation)}</span></td>
<td className="px-4 py-3">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => navigate(`/maintenance-teams/${team.name}`)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title={t('maintenance.viewDetails')}><FaEye /></button>
<button onClick={() => navigate(`/maintenance-teams/${team.name}`)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title={t('maintenance.editTeam')}><FaEdit /></button>
<button onClick={() => navigate(`/maintenance-teams/new?duplicate=${team.name}`)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title={t('maintenance.duplicateTeam')}><FaCopy /></button>
<button onClick={() => setDeleteConfirmOpen(team.name)} className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors" title={t('maintenance.deleteTeam')}><FaTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalCount)} of {totalCount} teams</div>
<div className="flex items-center gap-2">
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"><FaChevronLeft /></button>
<span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"><FaChevronRight /></button>
</div>
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-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-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"><FaTrash className="text-red-600 dark:text-red-400 text-xl" /></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Delete Maintenance Team</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Are you sure you want to delete this maintenance team? This action cannot be undone.</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4"><p className="text-xs text-yellow-800 dark:text-yellow-300"><strong>Team:</strong> {deleteConfirmOpen}</p></div>
<div className="flex gap-3 justify-end">
<button onClick={() => setDeleteConfirmOpen(null)} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">Cancel</button>
<button onClick={() => handleDelete(deleteConfirmOpen)} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2"><FaTrash />Delete Team</button>
</div>
</div>
</div>
</div>
</div>
)}
<style>{`
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.animate-scale-in { animation: scale-in 0.2s ease-out; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
`}</style>
</div>
);
};
export default MaintenanceTeamList;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ppmPlannerService, { type PPMPlannerFilters, type BulkScheduleData } from '../services/ppmPlannerService'; import ppmPlannerService, { type BulkScheduleData } from '../services/ppmPlannerService';
import { FaFilter, FaCalendar, FaCheckCircle, FaSearch, FaArrowLeft, FaSpinner } from 'react-icons/fa'; import { FaFilter, FaCalendar, FaCheckCircle, FaSearch, FaArrowLeft, FaSpinner } from 'react-icons/fa';
import LinkField from '../components/LinkField'; import LinkField from '../components/LinkField';
@ -62,7 +62,6 @@ const PPMPlanner: React.FC = () => {
const [successResult, setSuccessResult] = useState<{ const [successResult, setSuccessResult] = useState<{
show: boolean; show: boolean;
document?: string; document?: string;
schedules?: string[];
count: number; count: number;
type: 'pm_schedule' | 'maintenance_logs'; type: 'pm_schedule' | 'maintenance_logs';
} | null>(null); } | null>(null);
@ -303,8 +302,17 @@ const PPMPlanner: React.FC = () => {
setLoading(true); setLoading(true);
try { 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 = { const bulkData: BulkScheduleData = {
asset_names: selectedAssets, assets: selectedAssetDetails, // Pass full asset details
start_date: scheduleData.start_date, start_date: scheduleData.start_date,
end_date: scheduleData.end_date, end_date: scheduleData.end_date,
maintenance_team: scheduleData.maintenance_team || undefined, maintenance_team: scheduleData.maintenance_team || undefined,
@ -315,20 +323,24 @@ const PPMPlanner: React.FC = () => {
no_of_pms: scheduleData.no_of_pms || undefined, no_of_pms: scheduleData.no_of_pms || undefined,
pm_for: scheduleData.pm_for || undefined, pm_for: scheduleData.pm_for || undefined,
hospital: filters.company!, hospital: filters.company!,
// Form-level fields from filters
modality: filters.custom_modality, modality: filters.custom_modality,
manufacturer: filters.custom_manufacturer, manufacturer: filters.custom_manufacturer,
model: filters.custom_model, model: filters.custom_model,
department: scheduleData.department || filters.department || undefined, 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); const result = await ppmPlannerService.createBulkMaintenanceSchedules(bulkData);
setSuccessResult({ setSuccessResult({
show: true, show: true,
document: result.document, document: result.document,
schedules: result.schedules,
count: result.created || selectedAssets.length, count: result.created || selectedAssets.length,
type: 'pm_schedule' // Always PM Schedule Generator - Frappe will create maintenance logs automatically type: 'pm_schedule'
}); });
setSelectedAssets([]); setSelectedAssets([]);
@ -341,7 +353,8 @@ const PPMPlanner: React.FC = () => {
maintenance_manager: '', maintenance_manager: '',
periodicity: 'Monthly', periodicity: 'Monthly',
maintenance_type: 'Preventive', maintenance_type: 'Preventive',
no_of_pms: '' no_of_pms: '',
department: ''
}); });
} catch (error) { } catch (error) {
console.error('Error creating schedules:', error); console.error('Error creating schedules:', error);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -68,6 +68,8 @@ const WorkOrderDetail: React.FC = () => {
} }
}; };
const [isMaintenanceManager, setIsMaintenanceManager] = useState(false);
const { workOrder, loading, error, refetch } = useWorkOrderDetails( const { workOrder, loading, error, refetch } = useWorkOrderDetails(
isDuplicating ? duplicateFromWorkOrder : (isNewWorkOrder ? null : workOrderName || null) isDuplicating ? duplicateFromWorkOrder : (isNewWorkOrder ? null : workOrderName || null)
); );
@ -97,6 +99,7 @@ const WorkOrderDetail: React.FC = () => {
docstatus?: number; docstatus?: number;
// New fields // New fields
custom_assigned_supervisor?: string; custom_assigned_supervisor?: string;
custom_moh_supervisor?: string;
total_hours_spent?: number; total_hours_spent?: number;
custom_pending_reason?: string; custom_pending_reason?: string;
total_repair_cost?: number; total_repair_cost?: number;
@ -137,6 +140,7 @@ const WorkOrderDetail: React.FC = () => {
docstatus: 0, docstatus: 0,
// New fields // New fields
custom_assigned_supervisor: '', custom_assigned_supervisor: '',
custom_moh_supervisor: '',
total_hours_spent: 0, total_hours_spent: 0,
custom_pending_reason: '', custom_pending_reason: '',
total_repair_cost: 0, total_repair_cost: 0,
@ -435,7 +439,7 @@ const WorkOrderDetail: React.FC = () => {
try { try {
setIsLoadingAsset(true); setIsLoadingAsset(true);
const response = await apiService.apiCall<any>( 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"]&limit=1` `/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"]&limit=1`
); );
if (response?.data && response.data.length > 0) { if (response?.data && response.data.length > 0) {
return response.data[0]; return response.data[0];
@ -472,6 +476,7 @@ const WorkOrderDetail: React.FC = () => {
custom_site_contractor: assetData.custom_site_contractor || '', custom_site_contractor: assetData.custom_site_contractor || '',
custom_subcontractor: assetData.custom_subcontractor || '', custom_subcontractor: assetData.custom_subcontractor || '',
model: assetData.custom_model || '', model: assetData.custom_model || '',
site_name: assetData.custom_site || '',
// Service Agreement fields - auto-populate from asset // Service Agreement fields - auto-populate from asset
custom_service_agreement: assetData.custom_service_agreement || '', custom_service_agreement: assetData.custom_service_agreement || '',
custom_service_coverage: assetData.custom_service_coverage || '', custom_service_coverage: assetData.custom_service_coverage || '',
@ -501,6 +506,7 @@ const WorkOrderDetail: React.FC = () => {
custom_site_contractor: '', custom_site_contractor: '',
custom_subcontractor: '', custom_subcontractor: '',
model: '', model: '',
site_name: '',
// Clear service agreement fields // Clear service agreement fields
custom_service_agreement: '', custom_service_agreement: '',
custom_service_coverage: '', custom_service_coverage: '',
@ -562,6 +568,7 @@ const WorkOrderDetail: React.FC = () => {
custom_site_contractor: searchParams.get('site_contractor') || '', custom_site_contractor: searchParams.get('site_contractor') || '',
custom_subcontractor: searchParams.get('subcontractor') || '', custom_subcontractor: searchParams.get('subcontractor') || '',
company: searchParams.get('company') || '', company: searchParams.get('company') || '',
site_name: searchParams.get('site_name') || '',
}; };
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@ -607,6 +614,7 @@ const WorkOrderDetail: React.FC = () => {
docstatus: workOrder.docstatus || 0, docstatus: workOrder.docstatus || 0,
// New fields // New fields
custom_assigned_supervisor: workOrder.custom_assigned_supervisor || '', custom_assigned_supervisor: workOrder.custom_assigned_supervisor || '',
custom_moh_supervisor: workOrder.custom_moh_supervisor || '',
total_hours_spent: workOrder.total_hours_spent || 0, total_hours_spent: workOrder.total_hours_spent || 0,
custom_pending_reason: workOrder.custom_pending_reason || '', custom_pending_reason: workOrder.custom_pending_reason || '',
total_repair_cost: workOrder.total_repair_cost || 0, total_repair_cost: workOrder.total_repair_cost || 0,
@ -730,6 +738,25 @@ const WorkOrderDetail: React.FC = () => {
} }
}; };
// 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 // Function to call assign_supervisor_or_technician API before workflow action
// This mirrors Frappe's before_workflow_action client script behavior // This mirrors Frappe's before_workflow_action client script behavior
const callBeforeWorkflowAction = async (action: string): Promise<{ assigned_to: string | null } | null> => { const callBeforeWorkflowAction = async (action: string): Promise<{ assigned_to: string | null } | null> => {
@ -868,7 +895,17 @@ const WorkOrderDetail: React.FC = () => {
const stateStyle = getStateStyle(currentWorkflowState); const stateStyle = getStateStyle(currentWorkflowState);
// Check if editing is allowed based on workflow // Check if editing is allowed based on workflow
const canEditBasedOnWorkflow = isNewWorkOrder || (!workflowLoading && transitions.length > 0); // const canEditBasedOnWorkflow = isNewWorkOrder || (!workflowLoading && transitions.length > 0);
// 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';
const canEditBasedOnWorkflow =
isNewWorkOrder ||
(isSystemManager && currentDocstatus === 0) ||
(isMaintenanceManager && currentState === 'Sent to Team Leader') ||
(!workflowLoading && transitions.length > 0);
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
@ -1044,7 +1081,7 @@ const WorkOrderDetail: React.FC = () => {
</div> </div>
{/* Site Name - Only visible for Non Biomedical */} {/* Site Name - Only visible for Non Biomedical */}
{isNonBiomedical && ( {(isNonBiomedical || formData.company?.startsWith('Mobile')) && (
<div> <div>
<LinkField <LinkField
label="Site Name" label="Site Name"
@ -1411,20 +1448,20 @@ const WorkOrderDetail: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700"> <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">Location & Assignment</h2> <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">Location & Assignment</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div> <div>
<LinkField <LinkField
label="Department" label="MOH Supervisor"
doctype="Department" doctype="User"
value={formData.department || ''} value={formData.custom_moh_supervisor || ''}
onChange={(val) => setFormData({ ...formData, department: val })} onChange={(val) => setFormData({ ...formData, custom_moh_supervisor: val })}
disabled={!isEditing} disabled={!isEditing}
filters={departmentFilters}
/> />
</div> </div>
<div> <div>
<LinkField <LinkField
label="Assigned Supervisor" label="Team Leader"
doctype="User" doctype="User"
value={formData.custom_assigned_supervisor || ''} value={formData.custom_assigned_supervisor || ''}
onChange={(val) => setFormData({ ...formData, custom_assigned_supervisor: val })} onChange={(val) => setFormData({ ...formData, custom_assigned_supervisor: val })}
@ -1434,7 +1471,7 @@ const WorkOrderDetail: React.FC = () => {
<div> <div>
<LinkField <LinkField
label="Assigned Contractor" label="Assigned Technician"
doctype="User" doctype="User"
value={formData.custom_assign_to_contractor || ''} value={formData.custom_assign_to_contractor || ''}
onChange={(val) => setFormData({ ...formData, custom_assign_to_contractor: val })} onChange={(val) => setFormData({ ...formData, custom_assign_to_contractor: val })}
@ -1443,18 +1480,13 @@ const WorkOrderDetail: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"> <LinkField
Total Hours Spent label="Department"
</label> doctype="Department"
<input value={formData.department || ''}
type="number" onChange={(val) => setFormData({ ...formData, department: val })}
name="total_hours_spent"
min="0"
step="0.5"
value={formData.total_hours_spent || 0}
onChange={handleChange}
disabled={!isEditing} 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" filters={departmentFilters}
/> />
</div> </div>
@ -1494,6 +1526,22 @@ const WorkOrderDetail: React.FC = () => {
)} )}
</div> </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>
<div> <div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
First Responded On First Responded On

View File

@ -0,0 +1,241 @@
import API_CONFIG from '../config/api';
// Issue interfaces
export interface Issue {
name: string;
owner: string;
creation: string;
modified: string;
modified_by: string;
docstatus: number;
idx: number;
naming_series: string;
subject: string;
raised_by: string;
status: string;
priority?: string;
issue_type?: string;
description?: string;
opening_date: string;
opening_time: string;
contact?: string;
company?: string;
via_customer_portal: number;
resolution_by?: string;
resolution_date?: string;
first_responded_on?: string;
resolution_details?: string;
customer?: string;
project?: string;
issue_split_from?: string;
}
export interface CreateIssueData {
subject: string;
raised_by?: string;
status?: string;
priority?: string;
issue_type?: string;
description?: string;
contact?: string;
company?: string;
customer?: string;
project?: string;
resolution_details?: string;
first_responded_on?: string;
resolution_date?: string;
resolution_by?: string;
}
export interface IssueListParams {
filters?: Record<string, any>;
fields?: string[];
limit_start?: number;
limit_page_length?: number;
order_by?: string;
}
export interface IssueListResponse {
data: Issue[];
total_count?: number;
}
class IssueService {
private baseURL: string;
constructor() {
this.baseURL = API_CONFIG.BASE_URL;
}
// Get CSRF Token
private async getCSRFToken(): Promise<string | null> {
try {
if (typeof window !== 'undefined' && (window as any).csrf_token) {
return (window as any).csrf_token;
}
return null;
} catch (error) {
return null;
}
}
// Get headers with CSRF token
private async getHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
const csrfToken = await this.getCSRFToken();
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
return headers;
}
// Get list of issues
async getIssues(params: IssueListParams = {}): Promise<IssueListResponse> {
const {
filters = {},
fields = ['name', 'subject', 'raised_by', 'status', 'priority', 'issue_type', 'opening_date', 'company', 'contact', 'creation', 'modified', 'first_responded_on', 'resolution_date', 'resolution_by'],
limit_start = 0,
limit_page_length = 20,
order_by = 'creation desc',
} = params;
const queryParams = new URLSearchParams();
queryParams.append('fields', JSON.stringify(fields));
queryParams.append('limit_start', limit_start.toString());
queryParams.append('limit_page_length', limit_page_length.toString());
queryParams.append('order_by', order_by);
if (Object.keys(filters).length > 0) {
const filterArray = Object.entries(filters).map(([key, value]) => [key, '=', value]);
queryParams.append('filters', JSON.stringify(filterArray));
}
const response = await fetch(
`${this.baseURL}/api/resource/Issue?${queryParams.toString()}`,
{
method: 'GET',
headers: await this.getHeaders(),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return { data: result.data || [] };
}
// Get single issue details
async getIssue(name: string): Promise<Issue> {
const response = await fetch(
`${this.baseURL}/api/resource/Issue/${encodeURIComponent(name)}`,
{
method: 'GET',
headers: await this.getHeaders(),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data;
}
// Create new issue
async createIssue(data: CreateIssueData): Promise<Issue> {
const response = await fetch(
`${this.baseURL}/api/resource/Issue`,
{
method: 'POST',
headers: await this.getHeaders(),
credentials: 'include',
body: JSON.stringify(data),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data;
}
// Update issue
async updateIssue(name: string, data: Partial<CreateIssueData>): Promise<Issue> {
const response = await fetch(
`${this.baseURL}/api/resource/Issue/${encodeURIComponent(name)}`,
{
method: 'PUT',
headers: await this.getHeaders(),
credentials: 'include',
body: JSON.stringify(data),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data;
}
// Delete issue
async deleteIssue(name: string): Promise<void> {
const response = await fetch(
`${this.baseURL}/api/resource/Issue/${encodeURIComponent(name)}`,
{
method: 'DELETE',
headers: await this.getHeaders(),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// Get issue count
async getIssueCount(filters: Record<string, any> = {}): Promise<number> {
const queryParams = new URLSearchParams();
queryParams.append('fields', JSON.stringify(['count(name) as count']));
if (Object.keys(filters).length > 0) {
const filterArray = Object.entries(filters).map(([key, value]) => [key, '=', value]);
queryParams.append('filters', JSON.stringify(filterArray));
}
const response = await fetch(
`${this.baseURL}/api/resource/Issue?${queryParams.toString()}`,
{
method: 'GET',
headers: await this.getHeaders(),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data?.[0]?.count || 0;
}
}
const issueService = new IssueService();
export default issueService;

View File

@ -0,0 +1,247 @@
import API_CONFIG from '../config/api';
// Maintenance Team Member interface
export interface MaintenanceTeamMember {
name?: string;
team_member: string;
full_name?: string;
maintenance_role?: string;
idx?: number;
}
// Maintenance Team interface
export interface MaintenanceTeam {
name: string;
owner?: string;
creation?: string;
modified?: string;
modified_by?: string;
docstatus?: number;
maintenance_team_name: string;
maintenance_manager?: string;
maintenance_manager_name?: string;
company?: string;
custom_expertise?: string;
maintenance_team_members?: MaintenanceTeamMember[];
}
export interface CreateMaintenanceTeamData {
maintenance_team_name: string;
maintenance_manager?: string;
maintenance_manager_name?: string;
company?: string;
custom_expertise?: string;
maintenance_team_members?: MaintenanceTeamMember[];
}
export interface MaintenanceTeamListParams {
filters?: Record<string, any>;
fields?: string[];
limit_start?: number;
limit_page_length?: number;
order_by?: string;
}
export interface MaintenanceTeamListResponse {
data: MaintenanceTeam[];
total_count?: number;
}
class MaintenanceTeamService {
private baseURL: string;
constructor() {
this.baseURL = API_CONFIG.BASE_URL;
}
private async getCSRFToken(): Promise<string | null> {
try {
if (typeof window !== 'undefined' && (window as any).csrf_token) {
return (window as any).csrf_token;
}
return null;
} catch (error) {
return null;
}
}
private async getHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
const csrfToken = await this.getCSRFToken();
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
return headers;
}
// Get list of maintenance teams
async getMaintenanceTeams(params: MaintenanceTeamListParams = {}): Promise<MaintenanceTeamListResponse> {
const {
filters = {},
fields = ['name', 'maintenance_team_name', 'maintenance_manager', 'maintenance_manager_name', 'company', 'custom_expertise', 'creation', 'modified'],
limit_start = 0,
limit_page_length = 20,
order_by = 'creation desc',
} = params;
const queryParams = new URLSearchParams();
queryParams.append('fields', JSON.stringify(fields));
queryParams.append('limit_start', limit_start.toString());
queryParams.append('limit_page_length', limit_page_length.toString());
queryParams.append('order_by', order_by);
if (Object.keys(filters).length > 0) {
const filterArray = Object.entries(filters).map(([key, value]) => [key, '=', value]);
queryParams.append('filters', JSON.stringify(filterArray));
}
const response = await fetch(
`${this.baseURL}/api/resource/Asset Maintenance Team?${queryParams.toString()}`,
{
method: 'GET',
headers: await this.getHeaders(),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return { data: result.data || [] };
}
// Get single maintenance team details
async getMaintenanceTeam(name: string): Promise<MaintenanceTeam> {
const response = await fetch(
`${this.baseURL}/api/resource/Asset Maintenance Team/${encodeURIComponent(name)}`,
{
method: 'GET',
headers: await this.getHeaders(),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data;
}
// Create new maintenance team
async createMaintenanceTeam(data: CreateMaintenanceTeamData): Promise<MaintenanceTeam> {
const response = await fetch(
`${this.baseURL}/api/resource/Asset Maintenance Team`,
{
method: 'POST',
headers: await this.getHeaders(),
credentials: 'include',
body: JSON.stringify(data),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data;
}
// Update maintenance team
async updateMaintenanceTeam(name: string, data: Partial<CreateMaintenanceTeamData>): Promise<MaintenanceTeam> {
const response = await fetch(
`${this.baseURL}/api/resource/Asset Maintenance Team/${encodeURIComponent(name)}`,
{
method: 'PUT',
headers: await this.getHeaders(),
credentials: 'include',
body: JSON.stringify(data),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data;
}
// Delete maintenance team
async deleteMaintenanceTeam(name: string): Promise<void> {
const response = await fetch(
`${this.baseURL}/api/resource/Asset Maintenance Team/${encodeURIComponent(name)}`,
{
method: 'DELETE',
headers: await this.getHeaders(),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// Get maintenance team count
async getMaintenanceTeamCount(filters: Record<string, any> = {}): Promise<number> {
const queryParams = new URLSearchParams();
queryParams.append('fields', JSON.stringify(['count(name) as count']));
if (Object.keys(filters).length > 0) {
const filterArray = Object.entries(filters).map(([key, value]) => [key, '=', value]);
queryParams.append('filters', JSON.stringify(filterArray));
}
const response = await fetch(
`${this.baseURL}/api/resource/Asset Maintenance Team?${queryParams.toString()}`,
{
method: 'GET',
headers: await this.getHeaders(),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data?.[0]?.count || 0;
}
// Fetch user full name for maintenance manager
async getUserFullName(email: string): Promise<string> {
try {
const response = await fetch(
`${this.baseURL}/api/resource/User/${encodeURIComponent(email)}?fields=["full_name"]`,
{
method: 'GET',
headers: await this.getHeaders(),
credentials: 'include',
}
);
if (!response.ok) return '';
const result = await response.json();
return result.data?.full_name || '';
} catch {
return '';
}
}
}
const maintenanceTeamService = new MaintenanceTeamService();
export default maintenanceTeamService;

View File

@ -11,8 +11,14 @@ export interface PPMPlannerFilters {
company?: string; company?: string;
} }
export interface AssetEntry {
name: string;
custom_manufacturer?: string;
custom_model?: string;
}
export interface BulkScheduleData { export interface BulkScheduleData {
asset_names: string[]; assets: AssetEntry[];
start_date: string; start_date: string;
end_date: string; end_date: string;
maintenance_team?: string; maintenance_team?: string;
@ -182,13 +188,13 @@ class PPMPlannerService {
if (!data.periodicity) { if (!data.periodicity) {
throw new Error('Periodicity is required'); throw new Error('Periodicity is required');
} }
if (data.asset_names.length === 0) { if (data.assets.length === 0) {
throw new Error('At least one asset must be selected'); throw new Error('At least one asset must be selected');
} }
// Warn if too many assets (might cause timeout) // Warn if too many assets (might cause timeout)
if (data.asset_names.length > 50) { if (data.assets.length > 50) {
console.warn(`Creating schedules for ${data.asset_names.length} assets. This may take a while...`); console.warn(`Creating schedules for ${data.assets.length} assets. This may take a while...`);
} }
// Build the PM Schedule Generator document with correct structure // Build the PM Schedule Generator document with correct structure
@ -207,12 +213,15 @@ class PPMPlannerService {
manufacturer: data.manufacturer || null, manufacturer: data.manufacturer || null,
model: data.model || null, model: data.model || null,
department: data.department || null, department: data.department || null,
// Child table: PM Entry Line // Child table: PM Entry Line - each entry gets manufacturer and model from its own asset
maintenance_entries: data.asset_names.map(assetName => ({ maintenance_entries: data.assets.map(asset => ({
doctype: 'PM Entry Line', // Correct child doctype name doctype: 'PM Entry Line',
asset: assetName, // Link to Asset asset: asset.name,
start_date: data.start_date, start_date: data.start_date,
end_date: data.end_date end_date: data.end_date,
// Pick manufacturer and model from each asset, not from filter
manufacturer: asset.custom_manufacturer || null,
model: asset.custom_model || null,
})) }))
}; };
@ -259,9 +268,9 @@ class PPMPlannerService {
return { return {
success: true, success: true,
created: data.asset_names.length, created: data.assets.length,
document: docName, document: docName,
message: `PM Schedule Generator "${docName}" created and submitted with ${data.asset_names.length} assets` message: `PM Schedule Generator "${docName}" created and submitted with ${data.assets.length} assets`
}; };
} }
@ -310,9 +319,9 @@ class PPMPlannerService {
return { return {
success: true, success: true,
created: data.asset_names.length, created: data.assets.length,
document: docName, document: docName,
message: `PM Schedule Generator "${docName}" created and submitted with ${data.asset_names.length} assets` message: `PM Schedule Generator "${docName}" created and submitted with ${data.assets.length} assets`
}; };
} }
@ -322,8 +331,6 @@ class PPMPlannerService {
console.warn('Method 2 (Resource API) failed:', resourceError.message); console.warn('Method 2 (Resource API) failed:', resourceError.message);
// Both methods failed - provide helpful error message // Both methods failed - provide helpful error message
// Note: We do NOT create Asset Maintenance Log entries as fallback
// Frappe will automatically create them when PM Schedule Generator is submitted
const errorSummary: string[] = []; const errorSummary: string[] = [];
// Check if all errors are timeouts // Check if all errors are timeouts
@ -334,7 +341,7 @@ class PPMPlannerService {
if (allTimeouts) { if (allTimeouts) {
errorSummary.push( errorSummary.push(
`⚠️ Connection timeout detected. This usually means:\n` + `⚠️ Connection timeout detected. This usually means:\n` +
`• The server is taking too long to process ${data.asset_names.length} assets\n` + `• The server is taking too long to process ${data.assets.length} assets\n` +
`• Network connection is slow or unstable\n` + `• Network connection is slow or unstable\n` +
`• Server may be overloaded\n\n` + `• Server may be overloaded\n\n` +
`💡 Suggestions:\n` + `💡 Suggestions:\n` +
@ -395,28 +402,21 @@ class PPMPlannerService {
/** /**
* Get maintenance teams * Get maintenance teams
* Uses the correct doctype name: Asset Maintenance Team
* Standard Frappe fields: name (required), company (optional)
*/ */
async getMaintenanceTeams() { async getMaintenanceTeams() {
try { try {
// Use Asset Maintenance Team doctype - only request 'name' field (required)
// We'll use 'name' as both the value and display name
const response = await apiService.apiCall<any>( const response = await apiService.apiCall<any>(
`/api/resource/Asset Maintenance Team?fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1000` `/api/resource/Asset Maintenance Team?fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1000`
); );
if (response?.data && response.data.length > 0) { if (response?.data && response.data.length > 0) {
// Map the response - use 'name' as both identifier and display
return response.data.map((team: any) => ({ return response.data.map((team: any) => ({
name: team.name, name: team.name,
maintenance_team_name: team.name // Use name as display name maintenance_team_name: team.name
})); }));
} }
return []; return [];
} catch (error: any) { } catch (error: any) {
// Silently return empty array if doctype doesn't exist or fields are wrong
// Maintenance teams feature will work with manual text input
console.warn('Could not fetch maintenance teams:', error?.message || 'Unknown error'); console.warn('Could not fetch maintenance teams:', error?.message || 'Unknown error');
return []; return [];
} }
@ -433,7 +433,6 @@ class PPMPlannerService {
if (response?.data) { if (response?.data) {
const team = response.data; const team = response.data;
// Extract team members from the child table
const teamMembers: string[] = []; const teamMembers: string[] = [];
if (team.maintenance_team_members && Array.isArray(team.maintenance_team_members)) { if (team.maintenance_team_members && Array.isArray(team.maintenance_team_members)) {
team.maintenance_team_members.forEach((member: any) => { team.maintenance_team_members.forEach((member: any) => {
@ -458,5 +457,3 @@ class PPMPlannerService {
} }
export default new PPMPlannerService(); export default new PPMPlannerService();

View File

@ -46,6 +46,7 @@ export interface WorkOrder {
first_responded_on?: string; first_responded_on?: string;
penalty?: number; penalty?: number;
custom_assigned_supervisor?: string; custom_assigned_supervisor?: string;
custom_moh_supervisor?: string;
stock_consumption?: number; stock_consumption?: number;
need_procurement?: number; need_procurement?: number;
repair_cost?: number; repair_cost?: number;

View File

@ -517,6 +517,11 @@ export const getWorkflowStateStyle = (state: string): { bg: string; text: string
text: 'text-blue-800 dark:text-blue-200', text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-300 dark:border-blue-600' border: 'border-blue-300 dark:border-blue-600'
}, },
'Sent to Team Leader': {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-300 dark:border-blue-600'
},
'Sent to General WOA': { 'Sent to General WOA': {
bg: 'bg-blue-100 dark:bg-blue-900/30', bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-800 dark:text-blue-200', text: 'text-blue-800 dark:text-blue-200',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Seera Arabia Asset Management System" /> <meta name="description" content="Seera Arabia Asset Management System" />
<title>Seera Arabia - Asset Management System</title> <title>Seera Arabia - Asset Management System</title>
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-DrNfoAwJ.js"></script> <script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-DJlPs5uL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-BRxE8PT9.css"> <link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-vHgZyeJv.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -7,8 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Seera Arabia Asset Management System" /> <meta name="description" content="Seera Arabia Asset Management System" />
<title>Seera Arabia - Asset Management System</title> <title>Seera Arabia - Asset Management System</title>
<script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-DrNfoAwJ.js"></script> <script type="module" crossorigin src="/assets/asm_ui_app/asm_app/assets/index-DJlPs5uL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-BRxE8PT9.css"> <link rel="stylesheet" crossorigin href="/assets/asm_ui_app/asm_app/assets/index-vHgZyeJv.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>