latest changes of ppm
This commit is contained in:
parent
829a9227d8
commit
5274a58bd1
@ -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 />} />
|
||||||
|
|||||||
@ -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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
133
asm_app/src/hooks/useIssue.ts
Normal file
133
asm_app/src/hooks/useIssue.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
142
asm_app/src/hooks/useMaintenanceTeam.ts
Normal file
142
asm_app/src/hooks/useMaintenanceTeam.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
686
asm_app/src/pages/IssueDetail.tsx
Normal file
686
asm_app/src/pages/IssueDetail.tsx
Normal 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;
|
||||||
638
asm_app/src/pages/IssueList.tsx
Normal file
638
asm_app/src/pages/IssueList.tsx
Normal 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;
|
||||||
623
asm_app/src/pages/MaintenanceTeamDetail.tsx
Normal file
623
asm_app/src/pages/MaintenanceTeamDetail.tsx
Normal 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;
|
||||||
570
asm_app/src/pages/MaintenanceTeamList.tsx
Normal file
570
asm_app/src/pages/MaintenanceTeamList.tsx
Normal 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;
|
||||||
@ -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
@ -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
|
||||||
|
|||||||
241
asm_app/src/services/issueService.ts
Normal file
241
asm_app/src/services/issueService.ts
Normal 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;
|
||||||
247
asm_app/src/services/maintenanceTeamService.ts
Normal file
247
asm_app/src/services/maintenanceTeamService.ts
Normal 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;
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
1718
asm_ui_app/public/asm_app/assets/index-DJlPs5uL.js
Normal file
1718
asm_ui_app/public/asm_app/assets/index-DJlPs5uL.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
asm_ui_app/public/asm_app/assets/index-vHgZyeJv.css
Normal file
1
asm_ui_app/public/asm_app/assets/index-vHgZyeJv.css
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user