From 74243f4d34ed66c56a6d64922b98cb45e2c44986 Mon Sep 17 00:00:00 2001 From: Duradundi Date: Mon, 24 Aug 2026 07:43:54 +0000 Subject: [PATCH] RC1 Asm app latest changes --- asm_app/index.html | 4 +- asm_app/package-lock.json | 7 + asm_app/package.json | 1 + asm_app/src/App.tsx | 76 +- asm_app/src/components/ActivityLog.tsx | 406 ++++ asm_app/src/components/AppEntry.tsx | 46 + .../src/components/AssetStatusReportModal.tsx | 288 +++ asm_app/src/components/AssetUpDownCard.tsx | 60 + asm_app/src/components/ChartDetailModal.tsx | 667 ++++++ asm_app/src/components/CommentSection.tsx | 384 ++++ asm_app/src/components/Header.tsx | 40 +- asm_app/src/components/HorizontalBarChart.tsx | 68 + asm_app/src/components/LinkField.tsx | 11 +- .../src/components/ListFilterSortControls.tsx | 107 + asm_app/src/components/ListPagination.tsx | 138 ++ asm_app/src/components/MentionInput.tsx | 315 +++ asm_app/src/components/NotificationBell.tsx | 299 +-- asm_app/src/components/PieChart.tsx | 164 ++ .../src/components/QueryReportExportModal.tsx | 286 +++ .../RepairStatusCompletionBarChart.tsx | 147 ++ .../components/RepairStatusCompletionCard.tsx | 87 + asm_app/src/components/Sidebar.tsx | 125 +- asm_app/src/components/StackedBarChart.tsx | 114 + .../components/TechnicianHoursChartCard.tsx | 108 + .../TechnicianWorkOrderSummaryReportModal.tsx | 394 ++++ .../components/TechnicianWorkedChartCard.tsx | 104 + .../TechnicianWorkingHoursReportModal.tsx | 585 ++++++ asm_app/src/components/WoFeedbackForm.tsx | 259 +++ .../src/components/WoFeedbackReportModal.tsx | 336 +++ asm_app/src/components/WoFeedbackSummary.tsx | 69 + .../components/WorkOrderStatusReportModal.tsx | 396 ++++ asm_app/src/config/api.ts | 10 +- asm_app/src/contexts/SidebarLayoutContext.tsx | 33 + asm_app/src/hooks/useApi.ts | 8 +- asm_app/src/hooks/useAuditLogs.ts | 120 ++ asm_app/src/hooks/useComments.ts | 124 ++ asm_app/src/hooks/useDefaultHospital.ts | 56 + asm_app/src/hooks/useItem.ts | 43 +- asm_app/src/hooks/useListSortFilters.ts | 102 + asm_app/src/hooks/useRoleProfile.ts | 64 + asm_app/src/hooks/useUserDashboardFilters.ts | 88 + asm_app/src/hooks/useUserHospitalFilter.ts | 62 + asm_app/src/hooks/useUserPhccSiteFilter.ts | 37 + asm_app/src/hooks/useWorkOrder.ts | 51 +- asm_app/src/index.css | 9 + asm_app/src/locales/ar/translation.json | 90 +- asm_app/src/locales/en/translation.json | 90 +- asm_app/src/pages/ActiveMap.tsx | 277 ++- asm_app/src/pages/AssetDetail.tsx | 525 +++-- asm_app/src/pages/AssetList.tsx | 209 +- asm_app/src/pages/AssetMaintenanceDetail.tsx | 6 + asm_app/src/pages/AssetMaintenanceList.tsx | 42 +- asm_app/src/pages/IssueDetail.tsx | 42 +- asm_app/src/pages/IssueList.tsx | 93 +- asm_app/src/pages/ItemDetail.tsx | 28 + asm_app/src/pages/ItemList.tsx | 106 +- asm_app/src/pages/Login.tsx | 36 +- asm_app/src/pages/MaintenanceCalendarPage.tsx | 34 +- asm_app/src/pages/MaintenanceTeamDetail.tsx | 48 +- asm_app/src/pages/MaintenanceTeamList.tsx | 87 +- asm_app/src/pages/ModernDashboard.tsx | 1514 +++++++++----- asm_app/src/pages/PPMDetail.tsx | 6 + asm_app/src/pages/PPMPlannerDetail.tsx | 6 + asm_app/src/pages/PPMPlannerList.tsx | 10 +- asm_app/src/pages/UserProfilePage.tsx | 59 +- asm_app/src/pages/WoFeedbackPage.tsx | 46 + asm_app/src/pages/WorkOrderDetail.tsx | 323 ++- asm_app/src/pages/WorkOrderList.tsx | 321 +-- asm_app/src/services/apiService.ts | 140 +- .../src/services/assetDeviceStatusService.ts | 67 + asm_app/src/services/assetService.ts | 12 + .../src/services/assigneesStatusService.ts | 147 ++ asm_app/src/services/commentService.ts | 148 ++ asm_app/src/services/itemService.ts | 85 +- .../services/technicianWorkSummaryService.ts | 112 + .../services/technicianWorkingHoursService.ts | 104 + asm_app/src/services/userProfileService.ts | 121 +- .../src/services/woFeedbackReportService.ts | 86 + .../src/services/woFeedbackWebFormService.ts | 146 ++ asm_app/src/services/workOrderService.ts | 27 +- asm_app/src/services/workflowService.ts | 32 +- asm_app/src/utils/appRouting.ts | 58 + asm_app/src/utils/buildAssetUrl.ts | 33 + asm_app/src/utils/buildMaintenanceUrl.ts | 33 + asm_app/src/utils/buildWoUrl.ts | 36 + asm_app/src/utils/chartLabelUtils.ts | 51 + asm_app/src/utils/dateUtils.ts | 16 + asm_app/src/utils/frappeErrorMessage.ts | 146 ++ asm_app/src/utils/hospitalUtils.ts | 93 + asm_app/src/utils/listFilterUtils.ts | 113 + asm_app/src/utils/openHtmlReport.ts | 141 ++ asm_app/src/utils/reportModalStyles.ts | 27 + asm_app/src/utils/roleAccess.ts | 147 ++ asm_app/src/utils/userHospital.ts | 189 ++ asm_app/src/utils/woFeedbackChartUtils.ts | 68 + asm_app/src/utils/workOrderTimingUtils.ts | 63 + asm_app/yarn.lock | 5 + asm_ui_app/hooks.py | 5 +- .../public/asm_app/assets/index-DQ5dWWYO.js | 1863 +++++++++++++++++ .../public/asm_app/assets/index-DpwoT1cm.css | 1 - .../public/asm_app/assets/index-ZwRMhM5a.css | 1 + .../public/asm_app/assets/index-rQSfSDmu.js | 1740 --------------- asm_ui_app/public/asm_app/index.html | 8 +- asm_ui_app/www/asm_app.html | 8 +- asm_ui_app/www/asm_app.py | 2 + yarn.lock | 4 + 106 files changed, 13608 insertions(+), 3366 deletions(-) create mode 100644 asm_app/src/components/ActivityLog.tsx create mode 100644 asm_app/src/components/AppEntry.tsx create mode 100644 asm_app/src/components/AssetStatusReportModal.tsx create mode 100644 asm_app/src/components/AssetUpDownCard.tsx create mode 100644 asm_app/src/components/ChartDetailModal.tsx create mode 100644 asm_app/src/components/CommentSection.tsx create mode 100644 asm_app/src/components/HorizontalBarChart.tsx create mode 100644 asm_app/src/components/ListFilterSortControls.tsx create mode 100644 asm_app/src/components/ListPagination.tsx create mode 100644 asm_app/src/components/MentionInput.tsx create mode 100644 asm_app/src/components/PieChart.tsx create mode 100644 asm_app/src/components/QueryReportExportModal.tsx create mode 100644 asm_app/src/components/RepairStatusCompletionBarChart.tsx create mode 100644 asm_app/src/components/RepairStatusCompletionCard.tsx create mode 100644 asm_app/src/components/StackedBarChart.tsx create mode 100644 asm_app/src/components/TechnicianHoursChartCard.tsx create mode 100644 asm_app/src/components/TechnicianWorkOrderSummaryReportModal.tsx create mode 100644 asm_app/src/components/TechnicianWorkedChartCard.tsx create mode 100644 asm_app/src/components/TechnicianWorkingHoursReportModal.tsx create mode 100644 asm_app/src/components/WoFeedbackForm.tsx create mode 100644 asm_app/src/components/WoFeedbackReportModal.tsx create mode 100644 asm_app/src/components/WoFeedbackSummary.tsx create mode 100644 asm_app/src/components/WorkOrderStatusReportModal.tsx create mode 100644 asm_app/src/contexts/SidebarLayoutContext.tsx create mode 100644 asm_app/src/hooks/useAuditLogs.ts create mode 100644 asm_app/src/hooks/useComments.ts create mode 100644 asm_app/src/hooks/useDefaultHospital.ts create mode 100644 asm_app/src/hooks/useListSortFilters.ts create mode 100644 asm_app/src/hooks/useRoleProfile.ts create mode 100644 asm_app/src/hooks/useUserDashboardFilters.ts create mode 100644 asm_app/src/hooks/useUserHospitalFilter.ts create mode 100644 asm_app/src/hooks/useUserPhccSiteFilter.ts create mode 100644 asm_app/src/pages/WoFeedbackPage.tsx create mode 100644 asm_app/src/services/assetDeviceStatusService.ts create mode 100644 asm_app/src/services/assigneesStatusService.ts create mode 100644 asm_app/src/services/commentService.ts create mode 100644 asm_app/src/services/technicianWorkSummaryService.ts create mode 100644 asm_app/src/services/technicianWorkingHoursService.ts create mode 100644 asm_app/src/services/woFeedbackReportService.ts create mode 100644 asm_app/src/services/woFeedbackWebFormService.ts create mode 100644 asm_app/src/utils/appRouting.ts create mode 100644 asm_app/src/utils/buildAssetUrl.ts create mode 100644 asm_app/src/utils/buildMaintenanceUrl.ts create mode 100644 asm_app/src/utils/buildWoUrl.ts create mode 100644 asm_app/src/utils/chartLabelUtils.ts create mode 100644 asm_app/src/utils/dateUtils.ts create mode 100644 asm_app/src/utils/frappeErrorMessage.ts create mode 100644 asm_app/src/utils/hospitalUtils.ts create mode 100644 asm_app/src/utils/listFilterUtils.ts create mode 100644 asm_app/src/utils/openHtmlReport.ts create mode 100644 asm_app/src/utils/reportModalStyles.ts create mode 100644 asm_app/src/utils/roleAccess.ts create mode 100644 asm_app/src/utils/userHospital.ts create mode 100644 asm_app/src/utils/woFeedbackChartUtils.ts create mode 100644 asm_app/src/utils/workOrderTimingUtils.ts create mode 100644 asm_ui_app/public/asm_app/assets/index-DQ5dWWYO.js delete mode 100644 asm_ui_app/public/asm_app/assets/index-DpwoT1cm.css create mode 100644 asm_ui_app/public/asm_app/assets/index-ZwRMhM5a.css delete mode 100644 asm_ui_app/public/asm_app/assets/index-rQSfSDmu.js create mode 100644 asm_ui_app/www/asm_app.py create mode 100644 yarn.lock diff --git a/asm_app/index.html b/asm_app/index.html index 20dd348..bc215d2 100644 --- a/asm_app/index.html +++ b/asm_app/index.html @@ -2,8 +2,8 @@ - - + + Seera Arabia - Asset Management System diff --git a/asm_app/package-lock.json b/asm_app/package-lock.json index 0d86e73..eb2c8a6 100644 --- a/asm_app/package-lock.json +++ b/asm_app/package-lock.json @@ -12,6 +12,7 @@ "@types/react-router-dom": "^5.3.3", "axios": "^1.12.2", "frappe-react-sdk": "^1.13.0", + "html5-qrcode": "^2.3.8", "i18next": "^25.7.2", "i18next-browser-languagedetector": "^8.2.0", "leaflet": "^1.9.4", @@ -2437,6 +2438,12 @@ "void-elements": "3.1.0" } }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/i18next": { "version": "25.7.2", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz", diff --git a/asm_app/package.json b/asm_app/package.json index 8396870..aef4792 100644 --- a/asm_app/package.json +++ b/asm_app/package.json @@ -16,6 +16,7 @@ "@types/react-router-dom": "^5.3.3", "axios": "^1.12.2", "frappe-react-sdk": "^1.13.0", + "html5-qrcode": "^2.3.8", "i18next": "^25.7.2", "i18next-browser-languagedetector": "^8.2.0", "leaflet": "^1.9.4", diff --git a/asm_app/src/App.tsx b/asm_app/src/App.tsx index 3311afa..f821a0e 100644 --- a/asm_app/src/App.tsx +++ b/asm_app/src/App.tsx @@ -18,11 +18,12 @@ // export default App; -import React from 'react'; -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; import ModernDashboard from './pages/ModernDashboard'; +import WoFeedbackPage from './pages/WoFeedbackPage'; import UsersList from './pages/UsersList'; import EventsList from './pages/EventsList'; import AssetList from './pages/AssetList'; @@ -39,28 +40,68 @@ import PPMPlannerDetail from './pages/PPMPlannerDetail'; import MaintenanceCalendarPage from './pages/MaintenanceCalendarPage'; import YearlyPPMPlannerPage from './pages/YearlyPPMPlannerPage'; import ActiveMap from './pages/ActiveMap'; +import AppEntry from './components/AppEntry'; import ItemList from './pages/ItemList'; import ItemDetail from './pages/ItemDetail'; import ComingSoon from './pages/ComingSoon'; import Sidebar from './components/Sidebar'; import Header from './components/Header'; +import { useLanguage } from './contexts/LanguageContext'; +import { SidebarLayoutProvider, useSidebarLayout } from './contexts/SidebarLayoutContext'; import IssueList from './pages/IssueList'; import IssueDetail from './pages/IssueDetail'; import MaintenanceTeamList from './pages/MaintenanceTeamList'; import MaintenanceTeamDetail from './pages/MaintenanceTeamDetail'; import UserProfilePage from './pages/UserProfilePage'; +import { getDefaultRoute, getStoredRoleProfile, isEndUserRole } from './utils/roleAccess'; // Layout with Sidebar and Header -const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => { +const LayoutWithSidebarInner: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const location = useLocation(); + const { isRTL } = useLanguage(); + const { mobileOpen, closeMobileSidebar } = useSidebarLayout(); const user = localStorage.getItem('user'); const userEmail = user ? JSON.parse(user).email : ''; + useEffect(() => { + const mq = window.matchMedia('(min-width: 1024px)'); + const handleChange = () => { + if (mq.matches) closeMobileSidebar(); + }; + handleChange(); + mq.addEventListener('change', handleChange); + return () => mq.removeEventListener('change', handleChange); + }, [closeMobileSidebar]); + return (
- -
+ {mobileOpen && ( + + {collapsible && ( + + )} +
+
+ + {isExpanded && ( +
+ {loading && ( +
+ + Loading... +
+ )} + + {!loading && auditLogs.length === 0 && ( +
+
+
+
+
+

+ No changes recorded yet +

+
+
+ {creationDate && createdBy && ( + + )} +
+ )} + + {!loading && auditLogs.length > 0 && ( +
+
+
+ {visibleLogs.map((log, index) => ( + + ))} +
+ {auditLogs.length > initialVisible && ( +
+ +
+ )} + {creationDate && createdBy && ( +
+ +
+ )} +
+ )} +
+ )} +
+ ); +}; + +export default ActivityLog; diff --git a/asm_app/src/components/AppEntry.tsx b/asm_app/src/components/AppEntry.tsx new file mode 100644 index 0000000..580d343 --- /dev/null +++ b/asm_app/src/components/AppEntry.tsx @@ -0,0 +1,46 @@ +import React, { useEffect, useState } from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; +import { resolveAuthenticatedRoute } from '../utils/appRouting'; + +const AppEntry: React.FC = () => { + const navigate = useNavigate(); + const [target, setTarget] = useState(null); + + useEffect(() => { + let cancelled = false; + + resolveAuthenticatedRoute() + .then((route) => { + if (cancelled) return; + if (route) { + setTarget(route); + } else { + navigate('/login', { replace: true }); + } + }) + .catch(() => { + if (!cancelled) { + navigate('/login', { replace: true }); + } + }); + + return () => { + cancelled = true; + }; + }, [navigate]); + + if (target) { + return ; + } + + return ( +
+
+
+

Loading...

+
+
+ ); +}; + +export default AppEntry; diff --git a/asm_app/src/components/AssetStatusReportModal.tsx b/asm_app/src/components/AssetStatusReportModal.tsx new file mode 100644 index 0000000..ea33a71 --- /dev/null +++ b/asm_app/src/components/AssetStatusReportModal.tsx @@ -0,0 +1,288 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { FaBoxes, FaExternalLinkAlt, FaSync, FaTimes } from 'react-icons/fa'; +import { useTranslation } from 'react-i18next'; +import type { PieChartData } from './PieChart'; +import { buildAssetUrl } from '../utils/buildAssetUrl'; +import type { DashboardLocationFilters } from '../utils/hospitalUtils'; +import type { AssetDeviceStatusSummaryFilters } from '../services/assetDeviceStatusService'; +import { + compactFilterFieldWrapClass, + compactFilterInputClass, + compactFilterLabelClass, + compactToolbarBtnPrimary, +} from '../utils/reportModalStyles'; + +export type AssetStatusReportRow = { + name: string; + asset_name?: string; + custom_device_status?: string; + location?: string; + department?: string; + company?: string; + custom_site?: string; +}; + +type AssetStatusRefreshResult = { + chart: PieChartData | null; + total_assets: number; + assets: AssetStatusReportRow[]; +}; + +type AssetStatusReportModalProps = { + isOpen: boolean; + onClose: () => void; + chartData: PieChartData | null; + defaultFromDate?: string; + defaultToDate?: string; + locationFilters?: DashboardLocationFilters; + onRefresh?: (filters: AssetDeviceStatusSummaryFilters) => Promise; +}; + +type StatusCardStyle = { + bg: string; + border: string; + text: string; + bar: string; +}; + +const getStatusCardStyle = (label: string, chartColor?: string): StatusCardStyle => { + const key = (label || '').trim().toLowerCase(); + + if (key === 'up') { + return { bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-200 dark:border-emerald-800', text: 'text-emerald-700 dark:text-emerald-300', bar: '#10B981' }; + } + if (key === 'down') { + return { bg: 'bg-red-50 dark:bg-red-900/20', border: 'border-red-200 dark:border-red-800', text: 'text-red-700 dark:text-red-300', bar: '#EF4444' }; + } + if (key.includes('maintenance')) { + return { bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-200 dark:border-amber-800', text: 'text-amber-700 dark:text-amber-300', bar: '#F59E0B' }; + } + if (key.includes('decommission')) { + return { bg: 'bg-gray-50 dark:bg-gray-800', border: 'border-gray-200 dark:border-gray-700', text: 'text-gray-600 dark:text-gray-300', bar: '#6B7280' }; + } + + return { + bg: 'bg-indigo-50 dark:bg-indigo-900/20', + border: 'border-indigo-200 dark:border-indigo-800', + text: 'text-indigo-700 dark:text-indigo-300', + bar: chartColor || '#6366F1', + }; +}; + +const APP_BASE = '/asm_app'; + +const AssetStatusReportModal: React.FC = ({ + isOpen, + onClose, + chartData, + defaultFromDate = '', + defaultToDate = '', + locationFilters, + onRefresh, +}) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [fromDate, setFromDate] = useState(defaultFromDate); + const [toDate, setToDate] = useState(defaultToDate); + const [localChartData, setLocalChartData] = useState(chartData); + + useEffect(() => { + if (!isOpen) return; + setFromDate(defaultFromDate); + setToDate(defaultToDate); + setLocalChartData(chartData); + }, [isOpen, defaultFromDate, defaultToDate, chartData]); + + const buildFilters = useCallback((): AssetDeviceStatusSummaryFilters => { + const filters: AssetDeviceStatusSummaryFilters = {}; + if (locationFilters?.company) filters.company = locationFilters.company; + if (locationFilters?.site_name) filters.site_name = locationFilters.site_name; + if (fromDate) filters.from_date = fromDate; + if (toDate) filters.to_date = toDate; + return filters; + }, [fromDate, toDate, locationFilters]); + + const loadData = useCallback(async () => { + if (!onRefresh) return; + + setLoading(true); + try { + const result = await onRefresh(buildFilters()); + setLocalChartData(result.chart); + } catch (err) { + console.error('Failed to load asset status overview:', err); + setLocalChartData(null); + } finally { + setLoading(false); + } + }, [onRefresh, buildFilters]); + + useEffect(() => { + if (!isOpen || !onRefresh) return; + loadData(); + }, [isOpen, onRefresh]); // eslint-disable-line react-hooks/exhaustive-deps + + const totalAssets = localChartData?.total ?? 0; + + const statusRows = useMemo(() => { + const labels = localChartData?.labels || []; + const values = localChartData?.datasets?.[0]?.values || []; + const colors = localChartData?.datasets?.[0]?.colors || []; + + return labels.map((label, index) => ({ + label, + count: Number(values[index]) || 0, + color: colors[index], + percentage: totalAssets > 0 ? ((Number(values[index]) || 0) / totalAssets) * 100 : 0, + })); + }, [localChartData, totalAssets]); + + const navigateToAssets = useCallback( + (deviceStatus?: string) => { + const path = buildAssetUrl( + { device_status: deviceStatus, ...locationFilters }, + fromDate, + toDate, + locationFilters + ); + window.location.href = `${APP_BASE}${path}`; + }, + [fromDate, toDate, locationFilters] + ); + + if (!isOpen) return null; + + return ( +
+ +
+
+ +
+
+
+ + setFromDate(e.target.value)} + className={compactFilterInputClass} + /> +
+
+ + setToDate(e.target.value)} + className={compactFilterInputClass} + /> +
+ + {(locationFilters?.company || locationFilters?.site_name) && ( +
+ {locationFilters?.company && ( + + {locationFilters.company} + + )} + {locationFilters?.site_name && ( + + {locationFilters.site_name} + + )} +
+ )} +
+
+ +
+ + + {loading ? ( +
+
+
+ ) : ( +
+ {statusRows.map((row) => { + const style = getStatusCardStyle(row.label, row.color); + return ( + + ); + })} +
+ )} +
+
+
+ ); +}; + +export default AssetStatusReportModal; diff --git a/asm_app/src/components/AssetUpDownCard.tsx b/asm_app/src/components/AssetUpDownCard.tsx new file mode 100644 index 0000000..bd52181 --- /dev/null +++ b/asm_app/src/components/AssetUpDownCard.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { FaExternalLinkAlt } from 'react-icons/fa'; +import { useTranslation } from 'react-i18next'; +import PieChart, { type PieChartData } from './PieChart'; + +type AssetUpDownCardProps = { + title: string; + data: PieChartData | null; + loading?: boolean; + totalAssets: number; + onOpenReport: () => void; +}; + +const AssetUpDownCard: React.FC = ({ + title, + data, + loading = false, + totalAssets, + onOpenReport, +}) => { + const { t } = useTranslation(); + + return ( +
+
+
+

{title}

+

+ {totalAssets.toLocaleString()} {t('dashboard.totalAssetsLabel', { defaultValue: 'Total' })} +

+
+ +
+ + {loading ? ( +
+
+
+ ) : !data?.labels?.length ? ( +
+ {t('dashboard.noAssetStatusData', { defaultValue: 'No asset status data' })} +
+ ) : ( + + )} +
+ ); +}; + +export default AssetUpDownCard; diff --git a/asm_app/src/components/ChartDetailModal.tsx b/asm_app/src/components/ChartDetailModal.tsx new file mode 100644 index 0000000..1dc8310 --- /dev/null +++ b/asm_app/src/components/ChartDetailModal.tsx @@ -0,0 +1,667 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { FaSync, FaTimes } from 'react-icons/fa'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import apiService from '../services/apiService'; +import { + buildAssigneesStatusChartData, + getAssigneeCancelled, + getAssigneeCompleted, + getAssigneeName, + getAssigneeOverdue, + getAssigneePlanned, + getAssigneeTotal, + runAssigneesStatusReport, +} from '../services/assigneesStatusService'; +import { buildMaintenanceUrl } from '../utils/buildMaintenanceUrl'; +import { buildWoUrl } from '../utils/buildWoUrl'; +import { getRepairCompletionRate } from '../utils/chartLabelUtils'; +import type { DashboardLocationFilters } from '../utils/hospitalUtils'; + +type ChartDataset = { + name?: string; + values?: number[]; + colors?: string[]; +}; + +type ChartData = { + labels?: string[]; + datasets?: ChartDataset[]; + hideFooterTotal?: boolean; + rawRows?: Record[]; +}; + +type ChartDetailModalProps = { + isOpen: boolean; + onClose: () => void; + title: string; + initialData?: ChartData | null; + redirectContext?: 'wo_status' | 'wo_type' | 'assignees_status' | null; + defaultFromDate?: string; + defaultToDate?: string; + locationFilters?: DashboardLocationFilters; + workOrderType?: string; +}; + +const APP_BASE = '/asm_app'; + +const ChartDetailModal: React.FC = ({ + isOpen, + onClose, + title, + initialData = null, + redirectContext = null, + defaultFromDate = '', + defaultToDate = '', + locationFilters, + workOrderType = '', +}) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [fromDate, setFromDate] = useState(defaultFromDate); + const [toDate, setToDate] = useState(defaultToDate); + const [chartData, setChartData] = useState(initialData); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const buildMetricsFilters = useCallback( + (from: string, to: string) => { + const filters: Record = {}; + if (from) filters.from_date = from; + if (to) filters.to_date = to; + if (locationFilters?.company) filters.company = locationFilters.company; + if (locationFilters?.site_name) filters.site_name = locationFilters.site_name; + if (workOrderType) filters.work_order_type = workOrderType; + return filters; + }, + [locationFilters, workOrderType] + ); + + const loadData = useCallback( + async (from: string, to: string) => { + if (redirectContext === 'wo_status') { + setLoading(true); + setError(null); + try { + const result = await apiService.getDashboardWorkOrderMetrics(buildMetricsFilters(from, to)); + setChartData(result.work_order_chart); + } catch (err) { + setChartData(null); + setError(err instanceof Error ? err.message : 'Failed to load report data'); + } finally { + setLoading(false); + } + return; + } + + if (redirectContext === 'wo_type') { + setLoading(true); + setError(null); + try { + const result = await apiService.getDashboardWorkOrderMetrics(buildMetricsFilters(from, to)); + const rates = result.completion_by_type?.rates || []; + setChartData({ + labels: rates.map(item => item.type), + datasets: [ + { + name: 'Completion Rate (%)', + values: rates.map(item => getRepairCompletionRate(item)), + }, + { + name: 'Completed WOs', + values: rates.map( + item => item.completedCombined ?? (item.completed || 0) + (item.closed || 0) + ), + }, + { + name: 'In Progress', + values: rates.map(item => item.inProgress || 0), + }, + { + name: 'Rejected', + values: rates.map(item => item.rejected || 0), + }, + { + name: 'Total WOs', + values: rates.map(item => item.total || 0), + }, + ], + hideFooterTotal: true, + }); + } catch (err) { + setChartData(null); + setError(err instanceof Error ? err.message : 'Failed to load report data'); + } finally { + setLoading(false); + } + return; + } + + if (redirectContext === 'assignees_status') { + setLoading(true); + setError(null); + try { + const rows = await runAssigneesStatusReport(buildMetricsFilters(from, to)); + setChartData(buildAssigneesStatusChartData(rows)); + } catch (err) { + setChartData(null); + setError(err instanceof Error ? err.message : 'Failed to load report data'); + } finally { + setLoading(false); + } + return; + } + + setChartData(initialData); + }, + [redirectContext, initialData, buildMetricsFilters] + ); + + useEffect(() => { + if (!isOpen) return; + setFromDate(defaultFromDate); + setToDate(defaultToDate); + loadData(defaultFromDate, defaultToDate); + }, [isOpen, defaultFromDate, defaultToDate, loadData]); + + useEffect(() => { + if (!isOpen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isOpen, onClose]); + + const statusRows = useMemo(() => { + const labels = chartData?.labels || []; + const values = chartData?.datasets?.[0]?.values || []; + const colors = chartData?.datasets?.[0]?.colors || []; + return labels.map((label, index) => ({ + label, + value: Number(values[index]) || 0, + color: colors[index] || '#6366F1', + })); + }, [chartData]); + + const typeRows = useMemo(() => { + const labels = chartData?.labels || []; + const datasets = chartData?.datasets || []; + return labels.map((label, rowIndex) => ({ + label, + cells: datasets.map(dataset => Number(dataset.values?.[rowIndex]) || 0), + })); + }, [chartData]); + + const typeColumns = chartData?.datasets?.map(dataset => dataset.name || '') || []; + const statusTotal = useMemo(() => statusRows.reduce((sum, row) => sum + row.value, 0), [statusRows]); + + const assigneeRows = useMemo(() => { + const rawRows = chartData?.rawRows || []; + return rawRows.map(row => ({ + assignee: getAssigneeName(row), + planned: getAssigneePlanned(row), + completed: getAssigneeCompleted(row), + cancelled: getAssigneeCancelled(row), + overdue: getAssigneeOverdue(row), + total: getAssigneeTotal(row), + })); + }, [chartData?.rawRows]); + + const assigneeTotals = useMemo( + () => + assigneeRows.reduce( + (acc, row) => { + acc.planned += row.planned; + acc.completed += row.completed; + acc.cancelled += row.cancelled; + acc.overdue += row.overdue; + acc.total += row.total; + return acc; + }, + { planned: 0, completed: 0, cancelled: 0, overdue: 0, total: 0 } + ), + [assigneeRows] + ); + + const handleApply = () => { + loadData(fromDate, toDate); + }; + + const handleClearDates = () => { + setFromDate(''); + setToDate(''); + loadData('', ''); + }; + + const navigateToWorkOrders = (params: { + work_order_type?: string; + status?: string; + }) => { + const path = buildWoUrl( + { + work_order_type: params.work_order_type, + status: params.status, + company: locationFilters?.company, + site_name: locationFilters?.site_name, + }, + fromDate || undefined, + toDate || undefined, + locationFilters + ); + onClose(); + window.location.href = `${APP_BASE}${path}`; + }; + + const navigateToMaintenance = (assignee: string, status: string) => { + const path = buildMaintenanceUrl( + { assignee, status }, + fromDate || undefined, + toDate || undefined, + locationFilters + ); + onClose(); + window.location.href = `${APP_BASE}${path}`; + }; + + const handleAssigneeCellClick = (assignee: string, status: string, value: number) => { + if (redirectContext !== 'assignees_status' || value <= 0) return; + navigateToMaintenance(assignee, status); + }; + + const handleStatusRowClick = (status: string) => { + if (redirectContext !== 'wo_status') return; + onClose(); + navigate( + buildWoUrl({ status }, fromDate || undefined, toDate || undefined, locationFilters) + ); + }; + + const handleTypeCellClick = (rowLabel: string, columnName: string) => { + if (redirectContext !== 'wo_type') return; + + if (columnName === 'Rejected') { + navigateToWorkOrders({ work_order_type: rowLabel, status: 'Rejected' }); + return; + } + + if (columnName === 'In Progress') { + navigateToWorkOrders({ work_order_type: rowLabel, status: 'Work In Progress' }); + return; + } + + if (columnName === 'Completed WOs' || columnName === 'Completion Rate (%)') { + navigateToWorkOrders({ work_order_type: rowLabel, status: 'Completed,Closed' }); + return; + } + + navigateToWorkOrders({ work_order_type: rowLabel }); + }; + + const dateRangeLabel = + fromDate || toDate + ? t('dashboard.activeDateRange', { + defaultValue: 'Date range: {{from}} to {{to}}', + from: fromDate || '—', + to: toDate || '—', + }) + : t('dashboard.allDates', { defaultValue: 'Showing all dates' }); + + if (!isOpen) return null; + + const isTypeMode = redirectContext === 'wo_type'; + const isAssigneeMode = redirectContext === 'assignees_status' || !!(chartData?.rawRows?.length); + + return ( +
+
+
+
+
+

+ {title} +

+

+ {isTypeMode + ? t('dashboard.clickRowToFilter', { + defaultValue: 'Click a row or value to view filtered work orders', + }) + : dateRangeLabel} +

+
+ +
+
+ +
+
+
+ + setFromDate(e.target.value)} + className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm" + /> +
+
+ + setToDate(e.target.value)} + className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm" + /> +
+
+ + +
+
+ {isTypeMode && ( +

{dateRangeLabel}

+ )} +
+ +
+ {error && ( +
+ {error} +
+ )} + + {isTypeMode ? ( + + + + + {typeColumns.map(column => ( + + ))} + + + + {loading ? ( + + + + ) : typeRows.length === 0 ? ( + + + + ) : ( + typeRows.map(row => ( + + + {row.cells.map((value, index) => ( + + ))} + + )) + )} + +
+ {t('dashboard.woType', { defaultValue: 'WO Type' })} + + {column} +
+ {t('common.loading', { defaultValue: 'Loading...' })} +
+ {t('common.noData', { defaultValue: 'No data' })} +
+ + + +
+ ) : isAssigneeMode ? ( + + + + + + + + + + + + + {loading ? ( + + + + ) : assigneeRows.length === 0 ? ( + + + + ) : ( + assigneeRows.map(row => ( + + + + + + + + + )) + )} + + {!loading && assigneeRows.length > 0 && ( + + + + + + + + + + + )} +
+ {t('dashboard.assignedTo', { defaultValue: 'Assigned To' })} + + {t('dashboard.plannedTasks', { defaultValue: 'Planned' })} + + {t('dashboard.completedTasks', { defaultValue: 'Completed' })} + + {t('dashboard.cancelledTasks', { defaultValue: 'Cancelled' })} + + {t('dashboard.overdueTasks', { defaultValue: 'Overdue' })} + + {t('dashboard.totalTasks', { defaultValue: 'Total' })} +
+ {t('common.loading', { defaultValue: 'Loading...' })} +
+ {t('common.noData', { defaultValue: 'No data' })} +
{row.assignee} + handleAssigneeCellClick(row.assignee, 'Planned', row.planned)} + /> + + handleAssigneeCellClick(row.assignee, 'Completed', row.completed)} + /> + + handleAssigneeCellClick(row.assignee, 'Cancelled', row.cancelled)} + /> + + handleAssigneeCellClick(row.assignee, 'Overdue', row.overdue)} + /> + + {row.total} +
+ {t('dashboard.totalTasks', { defaultValue: 'Total' })} + + {assigneeTotals.planned} + + {assigneeTotals.completed} + + {assigneeTotals.cancelled} + + {assigneeTotals.overdue} + + {assigneeTotals.total} +
+ ) : ( + + + + + + + + + {loading ? ( + + + + ) : statusRows.length === 0 ? ( + + + + ) : ( + statusRows.map(row => ( + handleStatusRowClick(row.label)} + > + + + + )) + )} + + {!loading && statusRows.length > 0 && !chartData?.hideFooterTotal && ( + + + + + + + )} +
+ {t('commonFields.status', { defaultValue: 'Status' })} + + {t('dashboard.workOrders', { defaultValue: 'Work Orders' })} +
+ {t('common.loading', { defaultValue: 'Loading...' })} +
+ {t('common.noData', { defaultValue: 'No data' })} +
+
+ + {row.label} +
+
+ {row.value} +
+ {t('dashboard.totalWorkOrders', { defaultValue: 'Total Work Orders' })} + + {statusTotal} +
+ )} +
+
+
+ ); +}; + +export default ChartDetailModal; + +const AssigneeCountButton: React.FC<{ value: number; onClick: () => void }> = ({ value, onClick }) => { + if (value <= 0) { + return 0; + } + + return ( + + ); +}; diff --git a/asm_app/src/components/CommentSection.tsx b/asm_app/src/components/CommentSection.tsx new file mode 100644 index 0000000..5a55139 --- /dev/null +++ b/asm_app/src/components/CommentSection.tsx @@ -0,0 +1,384 @@ +import React, { useState, useMemo } from 'react'; +import { + FaComments, + FaUser, + FaTrash, + FaClock, + FaSpinner, + FaSync, + FaChevronDown, + FaChevronUp, + FaExclamationTriangle, + FaCheckCircle, + FaTimesCircle, + FaInfoCircle, + FaPaperclip, + FaThumbsUp, + FaEdit, +} from 'react-icons/fa'; +import { toast } from 'react-toastify'; +import { useComments } from '../hooks/useComments'; +import MentionInput from './MentionInput'; + +interface CommentSectionProps { + referenceDoctype: string; + referenceName: string | null; + title?: string; + pollInterval?: number; + initialLimit?: number; + collapsible?: boolean; + startCollapsed?: boolean; +} + +const commentTypeMeta: Record< + string, + { icon: React.ReactNode; color: string; label: string } +> = { + Comment: { + icon: , + color: 'text-blue-600 dark:text-blue-400', + label: 'Comment', + }, + Info: { + icon: , + color: 'text-gray-500 dark:text-gray-400', + label: 'Info', + }, + Edit: { + icon: , + color: 'text-orange-500 dark:text-orange-400', + label: 'Edit', + }, + Attachment: { + icon: , + color: 'text-purple-500 dark:text-purple-400', + label: 'Attachment', + }, + Like: { + icon: , + color: 'text-pink-500 dark:text-pink-400', + label: 'Like', + }, +}; + +const timeAgo = (dateStr: string): string => { + if (!dateStr) return ''; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const mins = Math.floor(diffMs / 60000); + const hrs = Math.floor(diffMs / 3600000); + const days = Math.floor(diffMs / 86400000); + + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins}m ago`; + if (hrs < 24) return `${hrs}h ago`; + if (days < 7) return `${days}d ago`; + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); +}; + +const emailToName = (email: string): string => { + if (!email) return 'Unknown'; + const at = email.indexOf('@'); + if (at === -1) return email; + return email + .substring(0, at) + .replace(/[._-]/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +}; + +const CommentContent: React.FC<{ html: string }> = ({ html }) => { + const cleaned = html + .replace(/
/g, '') + .replace(/<\/div>$/g, ''); + + return ( +
+ ); +}; + +const CommentSection: React.FC = ({ + referenceDoctype, + referenceName, + title = 'Comments & Discussion', + pollInterval = 30000, + initialLimit = 5, + collapsible = true, + startCollapsed = false, +}) => { + const { + comments, + loading, + posting, + error, + currentUser, + refetch, + postComment, + deleteComment, + mentionUsers, + mentionLoading, + searchMentionUsers, + } = useComments({ + referenceDoctype, + referenceName, + pollInterval, + }); + + const [expanded, setExpanded] = useState(!startCollapsed); + const [showAll, setShowAll] = useState(false); + const [draftText, setDraftText] = useState(''); + const [deletingId, setDeletingId] = useState(null); + + const userComments = useMemo( + () => comments.filter((c) => c.comment_type === 'Comment'), + [comments] + ); + + const visibleComments = useMemo(() => { + if (showAll) return comments; + return comments.slice(-initialLimit); + }, [comments, showAll, initialLimit]); + + const handlePost = async (html: string) => { + try { + await postComment(html); + setDraftText(''); + toast.success('Comment posted!', { + position: 'top-right', + autoClose: 2000, + icon: , + }); + } catch (err: any) { + toast.error(`Failed to post comment: ${err.message || 'Unknown error'}`, { + position: 'top-right', + autoClose: 5000, + icon: , + }); + } + }; + + const handleDelete = async (commentName: string) => { + setDeletingId(commentName); + try { + await deleteComment(commentName); + toast.success('Comment deleted', { + position: 'top-right', + autoClose: 2000, + icon: , + }); + } catch (err: any) { + toast.error(`Failed to delete: ${err.message || 'Unknown error'}`, { + position: 'top-right', + autoClose: 5000, + icon: , + }); + } finally { + setDeletingId(null); + } + }; + + if (!referenceName) { + return ( +
+
+ + Save the document first to enable comments. +
+
+ ); + } + + return ( +
+
collapsible && setExpanded((v) => !v)} + > +
+ +

+ {title} +

+ {userComments.length > 0 && ( + + {userComments.length} + + )} +
+
+ + {collapsible && ( + + {expanded ? : } + + )} +
+
+ + {expanded && ( +
+ {error && ( +
+ + {error} +
+ )} + + {loading && comments.length === 0 && ( +
+ + Loading comments… +
+ )} + + {comments.length > 0 && ( + <> + {!showAll && comments.length > initialLimit && ( + + )} + +
+ {visibleComments.map((comment) => { + const meta = commentTypeMeta[comment.comment_type] || commentTypeMeta.Comment; + const isOwn = comment.comment_email === currentUser || comment.owner === currentUser; + const isDeleting = deletingId === comment.name; + + return ( +
+
+
+
+
+ +
+
+ + {comment.comment_by || emailToName(comment.comment_email || comment.owner)} + + {comment.comment_type !== 'Comment' && ( + + {meta.icon} + {meta.label} + + )} +
+
+ +
+ + + {timeAgo(comment.creation)} + + + {isOwn && comment.comment_type === 'Comment' && ( + + )} +
+
+ +
+ +
+
+
+ ); + })} +
+ + {showAll && comments.length > initialLimit && ( + + )} + + )} + + {!loading && comments.length === 0 && ( +
+ +

+ No comments yet. Start the discussion! +

+
+ )} + +
+ +
+
+ )} +
+ ); +}; + +export default CommentSection; diff --git a/asm_app/src/components/Header.tsx b/asm_app/src/components/Header.tsx index 9f79fe2..b384b9e 100644 --- a/asm_app/src/components/Header.tsx +++ b/asm_app/src/components/Header.tsx @@ -3,20 +3,33 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { useTheme } from '../contexts/ThemeContext'; import { useLanguage } from '../contexts/LanguageContext'; import { useTranslation } from 'react-i18next'; -import { Moon, Sun, Languages, LogOut, UserCircle } from 'lucide-react'; +import { Moon, Sun, Languages, LogOut, UserCircle, Menu } from 'lucide-react'; import NotificationBell from './NotificationBell'; +import { useSidebarLayout } from '../contexts/SidebarLayoutContext'; interface HeaderProps { userEmail?: string; } +const getStoredUserDisplayName = (): string => { + try { + const user = localStorage.getItem('user'); + if (!user) return ''; + const parsed = JSON.parse(user); + return parsed.full_name || parsed.email || parsed.user_id || parsed.name || ''; + } catch { + return ''; + } +}; + const Header: React.FC = ({ userEmail }) => { const navigate = useNavigate(); const location = useLocation(); const { theme, toggleTheme } = useTheme(); const { language, changeLanguage } = useLanguage(); const { t } = useTranslation(); - const [userDisplayName, setUserDisplayName] = useState(''); + const [userDisplayName, setUserDisplayName] = useState(getStoredUserDisplayName); + const { openMobileSidebar } = useSidebarLayout(); useEffect(() => { const fetchUserDisplayName = async () => { @@ -86,20 +99,34 @@ const Header: React.FC = ({ userEmail }) => { const isProfileActive = location.pathname === '/user-profile'; return ( -
+
+ + +
@@ -129,6 +156,7 @@ const Header: React.FC = ({ userEmail }) => { > +
); }; diff --git a/asm_app/src/components/HorizontalBarChart.tsx b/asm_app/src/components/HorizontalBarChart.tsx new file mode 100644 index 0000000..23e1778 --- /dev/null +++ b/asm_app/src/components/HorizontalBarChart.tsx @@ -0,0 +1,68 @@ +import React, { useMemo } from 'react'; + +type HorizontalBarDataset = { + name: string; + values: number[]; + colors?: string[]; +}; + +type HorizontalBarChartProps = { + labels: string[]; + datasets: HorizontalBarDataset[]; + valueLabel?: string; +}; + +const HorizontalBarChart: React.FC = ({ + labels, + datasets, + valueLabel = '', +}) => { + const primaryDataset = datasets[0]; + const values = primaryDataset?.values || []; + + const maxValue = useMemo(() => Math.max(...values.map(value => Number(value) || 0), 1), [values]); + + if (!labels.length || !primaryDataset) { + return ( +
+ No chart data available +
+ ); + } + + const formatValue = (value: number) => { + const rounded = Math.round(value * 100) / 100; + return valueLabel ? `${rounded} ${valueLabel}` : String(rounded); + }; + + return ( +
+ {labels.map((label, rowIndex) => { + const value = Number(values[rowIndex]) || 0; + const widthPct = (value / maxValue) * 100; + + return ( +
+
+ {label} +
+
+
0 ? '4px' : 0, + }} + /> +
+
+ {formatValue(value)} +
+
+ ); + })} +
+ ); +}; + +export default HorizontalBarChart; diff --git a/asm_app/src/components/LinkField.tsx b/asm_app/src/components/LinkField.tsx index 02d1dda..8234ed9 100644 --- a/asm_app/src/components/LinkField.tsx +++ b/asm_app/src/components/LinkField.tsx @@ -10,6 +10,7 @@ interface LinkFieldProps { placeholder?: string; disabled?: boolean; filters?: Record; + query?: string; compact?: boolean; usePortal?: boolean; // New prop to enable portal rendering } @@ -25,6 +26,7 @@ const LinkField: React.FC = ({ placeholder, disabled = false, filters, + query, compact = false, usePortal = false, // Default to false for backward compatibility }) => { @@ -45,11 +47,12 @@ const LinkField: React.FC = ({ // Stringify filters for comparison (avoid object reference issues) const filtersKey = useMemo(() => JSON.stringify(stableFilters), [stableFilters]); + const queryKey = query || ''; // Fetch link options from ERPNext with filters const searchLink = useCallback(async (text: string = '', force: boolean = false) => { // Prevent duplicate calls for the same search text - const searchKey = `${text}-${filtersKey}`; + const searchKey = `${text}-${filtersKey}-${queryKey}`; if (!force && lastSearchRef.current === searchKey) { return; } @@ -67,6 +70,10 @@ const LinkField: React.FC = ({ params.append('filters', JSON.stringify(stableFilters)); } + if (query) { + params.append('query', query); + } + const response = await apiService.apiCall<{ value: string; description?: string }[]>( `/api/method/frappe.desk.search.search_link?${params.toString()}` ); @@ -77,7 +84,7 @@ const LinkField: React.FC = ({ } finally { setIsLoading(false); } - }, [doctype, filtersKey, stableFilters]); + }, [doctype, filtersKey, queryKey, stableFilters, query]); // Debounced search for typing const debouncedSearch = useCallback((text: string) => { diff --git a/asm_app/src/components/ListFilterSortControls.tsx b/asm_app/src/components/ListFilterSortControls.tsx new file mode 100644 index 0000000..cecebfb --- /dev/null +++ b/asm_app/src/components/ListFilterSortControls.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import type { DateFilterField } from '../utils/listFilterUtils'; + +interface ListFilterSortControlsProps { + sortBy: string; + dateFilterBy: DateFilterField; + dateStart: string; + dateEnd: string; + onSortByChange: (value: string) => void; + onDateFilterByChange: (value: DateFilterField) => void; + onDateStartChange: (value: string) => void; + onDateEndChange: (value: string) => void; + /** Show asset_name sort options (Asset list only) */ + includeAssetNameSort?: boolean; +} + +/** Inline sort + date filter fields — place inside the same filter grid as other filters */ +const ListFilterSortControls: React.FC = ({ + sortBy, + dateFilterBy, + dateStart, + dateEnd, + onSortByChange, + onDateFilterByChange, + onDateStartChange, + onDateEndChange, + includeAssetNameSort = false, +}) => { + const { t } = useTranslation(); + + const fieldClass = + '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'; + + return ( + <> +
+ + +
+ +
+ + +
+ + {dateFilterBy && ( + <> +
+ + { + const v = e.target.value; + onDateStartChange(v); + if (dateEnd && v > dateEnd) onDateEndChange(v); + }} + className={fieldClass} + /> +
+
+ + onDateEndChange(e.target.value)} + min={dateStart || undefined} + className={fieldClass} + /> +
+ + )} + + ); +}; + +export default ListFilterSortControls; diff --git a/asm_app/src/components/ListPagination.tsx b/asm_app/src/components/ListPagination.tsx new file mode 100644 index 0000000..38da476 --- /dev/null +++ b/asm_app/src/components/ListPagination.tsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface ListPaginationProps { + currentPage: number; + totalCount?: number; + pageSize: number; + hasMore?: boolean; + itemLabel?: string; + onPageChange: (page: number) => void; + className?: string; +} + +const ListPagination: React.FC = ({ + currentPage, + totalCount = 0, + pageSize, + hasMore = false, + itemLabel, + onPageChange, + className = '', +}) => { + const { t } = useTranslation(); + const displayLabel = itemLabel ?? t('listPages.results'); + const totalPages = totalCount > 0 ? Math.max(1, Math.ceil(totalCount / pageSize)) : 0; + const hasTotal = totalCount > 0; + const start = (currentPage - 1) * pageSize + 1; + const end = hasTotal + ? Math.min(currentPage * pageSize, totalCount) + : currentPage * pageSize; + + const [goToInput, setGoToInput] = useState(''); + + const handleGoToSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const num = parseInt(goToInput.trim(), 10); + if (!Number.isNaN(num) && num >= 1) { + const target = hasTotal ? Math.min(num, totalPages) : num; + onPageChange(target); + setGoToInput(''); + } + }; + + const getPageNumbers = (): (number | 'ellipsis')[] => { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + const pages: (number | 'ellipsis')[] = []; + pages.push(1); + if (currentPage > 3) pages.push('ellipsis'); + for (let p = Math.max(2, currentPage - 1); p <= Math.min(totalPages - 1, currentPage + 1); p++) { + if (!pages.includes(p)) pages.push(p); + } + if (currentPage < totalPages - 2) pages.push('ellipsis'); + if (totalPages > 1) pages.push(totalPages); + return pages; + }; + + const showPagination = hasMore || currentPage > 1 || (hasTotal && totalPages > 1); + + if (!showPagination) return null; + + return ( +
+
+ {hasTotal + ? t('pagination.showingToOf', { start, end, total: totalCount, label: displayLabel }) + : t('pagination.showingTo', { start, end, label: displayLabel })} +
+ +
+ + + {hasTotal && totalPages > 1 && ( +
+ {getPageNumbers().map((p, i) => + p === 'ellipsis' ? ( + + … + + ) : ( + + ) + )} +
+ )} + + + +
+ {t('pagination.goTo')} + setGoToInput(e.target.value)} + placeholder={hasTotal ? `1-${totalPages}` : t('pagination.page')} + className="w-14 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+
+
+ ); +}; + +export default ListPagination; diff --git a/asm_app/src/components/MentionInput.tsx b/asm_app/src/components/MentionInput.tsx new file mode 100644 index 0000000..5fa8765 --- /dev/null +++ b/asm_app/src/components/MentionInput.tsx @@ -0,0 +1,315 @@ +import React, { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react'; +import { FaSpinner, FaUser } from 'react-icons/fa'; +import type { MentionUser } from '../services/commentService'; +import API_CONFIG from '../config/api'; + +interface MentionInputProps { + value: string; + onChange: (text: string) => void; + onSubmit: (html: string) => void; + placeholder?: string; + disabled?: boolean; + mentionUsers: MentionUser[]; + mentionLoading: boolean; + onMentionSearch: (query: string) => void; + posting?: boolean; +} + +interface InsertedMention { + startIndex: number; + displayText: string; + userId: string; + fullName: string; +} + +const MentionInput: React.FC = ({ + value, + onChange, + onSubmit, + placeholder = 'Type a comment… Use @ to mention someone', + disabled = false, + mentionUsers, + mentionLoading, + onMentionSearch, + posting = false, +}) => { + const textareaRef = useRef(null); + const dropdownRef = useRef(null); + + const [showMentionDropdown, setShowMentionDropdown] = useState(false); + const [mentionQuery, setMentionQuery] = useState(''); + const [mentionStartPos, setMentionStartPos] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); + const [insertedMentions, setInsertedMentions] = useState([]); + + const updateDropdownPosition = useCallback(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.getBoundingClientRect(); + ta.offsetParent?.getBoundingClientRect(); + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + const cursorPos = e.target.selectionStart ?? 0; + onChange(newValue); + + const textBeforeCursor = newValue.substring(0, cursorPos); + const lastAtIndex = textBeforeCursor.lastIndexOf('@'); + + if (lastAtIndex !== -1) { + const charBefore = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' '; + if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) { + const query = textBeforeCursor.substring(lastAtIndex + 1); + if (!query.includes(' ') || query.length <= 30) { + setShowMentionDropdown(true); + setMentionQuery(query); + setMentionStartPos(lastAtIndex); + setSelectedIndex(0); + onMentionSearch(query); + updateDropdownPosition(); + return; + } + } + } + + setShowMentionDropdown(false); + setMentionStartPos(null); + }; + + const insertMention = useCallback( + (user: MentionUser) => { + if (mentionStartPos === null) return; + + const ta = textareaRef.current; + const before = value.substring(0, mentionStartPos); + const cursorPos = ta?.selectionStart ?? mentionStartPos + mentionQuery.length + 1; + const after = value.substring(cursorPos); + + const displayText = user.full_name || user.name; + const newText = `${before}@${displayText} ${after}`; + + setInsertedMentions((prev) => [ + ...prev, + { + startIndex: mentionStartPos, + displayText, + userId: user.name, + fullName: user.full_name || user.name, + }, + ]); + + onChange(newText); + setShowMentionDropdown(false); + setMentionStartPos(null); + setMentionQuery(''); + + setTimeout(() => { + if (ta) { + ta.focus(); + const newPos = before.length + displayText.length + 2; + ta.selectionStart = newPos; + ta.selectionEnd = newPos; + } + }, 0); + }, + [mentionStartPos, mentionQuery, value, onChange] + ); + + const buildHtml = useCallback( + (text: string): string => { + let html = text; + const baseUrl = API_CONFIG.BASE_URL || window.location.origin; + const sorted = [...insertedMentions].sort((a, b) => b.startIndex - a.startIndex); + + for (const m of sorted) { + const mentionText = `@${m.displayText}`; + const idx = html.indexOf(mentionText); + if (idx === -1) continue; + + const profileUrl = `${baseUrl}/app/user-profile/${encodeURIComponent(m.userId)}`; + const mentionHtml = + `` + + `\uFEFF` + + `@` + + `${m.fullName}` + + `\uFEFF`; + + html = html.substring(0, idx) + mentionHtml + html.substring(idx + mentionText.length); + } + + html = html.replace(/\n/g, '
'); + return `

${html}

`; + }, + [insertedMentions] + ); + + const handleSubmit = () => { + const trimmed = value.trim(); + if (!trimmed || posting) return; + const html = buildHtml(trimmed); + onSubmit(html); + setInsertedMentions([]); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (showMentionDropdown && mentionUsers.length > 0) { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, mentionUsers.length - 1)); + return; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + return; + case 'Enter': + e.preventDefault(); + insertMention(mentionUsers[selectedIndex]); + return; + case 'Escape': + e.preventDefault(); + setShowMentionDropdown(false); + return; + } + } + + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSubmit(); + } + }; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) && + textareaRef.current && + !textareaRef.current.contains(e.target as Node) + ) { + setShowMentionDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + if (!dropdownRef.current) return; + const item = dropdownRef.current.querySelector(`[data-idx="${selectedIndex}"]`); + item?.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + const baseUrl = API_CONFIG.BASE_URL || ''; + + return ( +
+ {showMentionDropdown && ( +
+ {mentionLoading && mentionUsers.length === 0 ? ( +
+ + Searching users… +
+ ) : mentionUsers.length === 0 ? ( +
+ No users found +
+ ) : ( + mentionUsers.map((user, idx) => ( + + )) + )} +
+ )} + +
+