From a0124f24f62439412551e8a6d84328bdeb813d50 Mon Sep 17 00:00:00 2001 From: Duradundi Hadimani Date: Wed, 26 Nov 2025 20:11:28 +0530 Subject: [PATCH] Added user Permissions logic and Updated filters --- src/components/LinkField.tsx | 106 -------- src/config/api.ts | 11 +- src/hooks/useAsset.ts | 63 ++++- src/hooks/useUserPermissions.ts | 188 ++++++++++++++ src/pages/AssetDetail.tsx | 173 ------------- src/pages/AssetList.tsx | 446 +++++++++++++++++++++----------- src/services/apiService.ts | 74 ++++++ 7 files changed, 619 insertions(+), 442 deletions(-) create mode 100644 src/hooks/useUserPermissions.ts diff --git a/src/components/LinkField.tsx b/src/components/LinkField.tsx index deffe75..628d08d 100644 --- a/src/components/LinkField.tsx +++ b/src/components/LinkField.tsx @@ -167,109 +167,3 @@ const LinkField: React.FC = ({ }; export default LinkField; - - -// import React, { useState, useEffect, useRef } from 'react'; -// import apiService from '../services/apiService'; // ✅ your ApiService - -// interface LinkFieldProps { -// label: string; -// doctype: string; -// value: string; -// onChange: (value: string) => void; -// placeholder?: string; -// disabled?: boolean; -// filters?: Record -// } - -// const LinkField: React.FC = ({ -// label, -// doctype, -// value, -// onChange, -// placeholder, -// disabled = false, -// }) => { -// const [searchResults, setSearchResults] = useState<{ value: string; description?: string }[]>([]); -// const [searchText, setSearchText] = useState(''); -// const [isDropdownOpen, setDropdownOpen] = useState(false); -// const containerRef = useRef(null); - -// // Fetch link options from ERPNext -// const searchLink = async (text: string = '') => { -// try { -// const params = new URLSearchParams({ doctype, txt: text }); -// const response = await apiService.apiCall<{ value: string; description?: string }[]>( -// `/api/method/frappe.desk.search.search_link?${params.toString()}` -// ); -// setSearchResults(response || []); -// } catch (error) { -// console.error(`Error fetching ${doctype} links:`, error); -// } -// }; - -// // Fetch default options when dropdown opens -// useEffect(() => { -// if (isDropdownOpen) searchLink(''); -// }, [isDropdownOpen]); - -// // Close dropdown when clicking outside -// useEffect(() => { -// const handleClickOutside = (event: MouseEvent) => { -// if (containerRef.current && !containerRef.current.contains(event.target as Node)) { -// setDropdownOpen(false); -// } -// }; -// document.addEventListener('mousedown', handleClickOutside); -// return () => document.removeEventListener('mousedown', handleClickOutside); -// }, []); - -// return ( -//
-// - -// !disabled && setDropdownOpen(true)} -// onChange={(e) => { -// const text = e.target.value; -// setSearchText(text); -// searchLink(text); -// onChange(text); -// }} -// /> - -// {isDropdownOpen && searchResults.length > 0 && !disabled && ( -//
    -// {searchResults.map((item, idx) => ( -//
  • { -// onChange(item.value); -// setDropdownOpen(false); -// }} -// className={`px-3 py-2 cursor-pointer -// text-gray-900 dark:text-gray-100 -// hover:bg-blue-500 dark:hover:bg-blue-600 -// ${value === item.value ? 'bg-blue-50 dark:bg-blue-700 font-semibold' : ''}`} -// > -// {item.value} -// {item.description && ( -// {item.description} -// )} -//
  • -// ))} -//
-// )} -//
-// ); -// }; - -// export default LinkField; diff --git a/src/config/api.ts b/src/config/api.ts index afc9602..36f8090 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -75,7 +75,16 @@ const API_CONFIG: ApiConfig = { CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token', // File Upload - UPLOAD_FILE: '/api/method/upload_file' + UPLOAD_FILE: '/api/method/upload_file', + + // User Permission Management - Generic (only these are needed!) + GET_USER_PERMISSIONS: '/api/method/asset_lite.api.userperm_api.get_user_permissions', + GET_PERMISSION_FILTERS: '/api/method/asset_lite.api.userperm_api.get_permission_filters', + GET_ALLOWED_VALUES: '/api/method/asset_lite.api.userperm_api.get_allowed_values', + CHECK_DOCUMENT_ACCESS: '/api/method/asset_lite.api.userperm_api.check_document_access', + GET_CONFIGURED_DOCTYPES: '/api/method/asset_lite.api.userperm_api.get_configured_doctypes', + GET_USER_DEFAULTS: '/api/method/asset_lite.api.userperm_api.get_user_defaults', + }, // Request Configuration diff --git a/src/hooks/useAsset.ts b/src/hooks/useAsset.ts index e4a26bc..2d11bcd 100644 --- a/src/hooks/useAsset.ts +++ b/src/hooks/useAsset.ts @@ -3,13 +3,57 @@ import assetService from '../services/assetService'; import type { Asset, AssetFilters, AssetFilterOptions, AssetStats, CreateAssetData } from '../services/assetService'; /** - * Hook to fetch list of assets with filters and pagination + * Merge user filters with permission filters + * Permission filters take precedence for security + */ +const mergeFilters = ( + userFilters: AssetFilters | undefined, + permissionFilters: Record +): AssetFilters => { + const merged: AssetFilters = { ...(userFilters || {}) }; + + // Apply permission filters (they take precedence for security) + for (const [field, value] of Object.entries(permissionFilters)) { + if (!merged[field as keyof AssetFilters]) { + // No user filter on this field, apply permission filter directly + (merged as any)[field] = value; + } else if (Array.isArray(value) && value[0] === 'in') { + // Permission filter is ["in", [...values]] + const permittedValues = value[1] as string[]; + const userValue = merged[field as keyof AssetFilters]; + + if (typeof userValue === 'string') { + // User selected a specific value, check if it's permitted + if (!permittedValues.includes(userValue)) { + // User selected a value they don't have permission for + // Set to empty array to return no results + (merged as any)[field] = ['in', []]; + } + // If permitted, keep the user's specific selection + } else if (Array.isArray(userValue) && userValue[0] === 'in') { + // Both are ["in", [...]] format, intersect them + const userValues = userValue[1] as string[]; + const intersection = userValues.filter(v => permittedValues.includes(v)); + (merged as any)[field] = ['in', intersection]; + } else { + // Other filter types, apply permission filter + (merged as any)[field] = value; + } + } + } + + return merged; +}; + +/** + * Hook to fetch list of assets with filters, pagination, and permission-based filtering */ export function useAssets( filters?: AssetFilters, limit: number = 20, offset: number = 0, - orderBy?: string + orderBy?: string, + permissionFilters: Record = {} // ← NEW: Permission filters parameter ) { const [assets, setAssets] = useState([]); const [totalCount, setTotalCount] = useState(0); @@ -21,6 +65,7 @@ export function useAssets( // Stringify filters to prevent object reference changes from causing re-renders const filtersJson = JSON.stringify(filters); + const permissionFiltersJson = JSON.stringify(permissionFilters); // ← NEW useEffect(() => { // Prevent fetching if already attempted and has error @@ -35,7 +80,14 @@ export function useAssets( try { setLoading(true); - const response = await assetService.getAssets(filters, undefined, limit, offset, orderBy); + // ✅ NEW: Merge user filters with permission filters + const mergedFilters = mergeFilters(filters, permissionFilters); + + console.log('[useAssets] User filters:', filters); + console.log('[useAssets] Permission filters:', permissionFilters); + console.log('[useAssets] Merged filters:', mergedFilters); + + const response = await assetService.getAssets(mergedFilters, undefined, limit, offset, orderBy); if (!isCancelled) { setAssets(response.assets); @@ -72,7 +124,7 @@ export function useAssets( isCancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filtersJson, limit, offset, orderBy, refetchTrigger]); + }, [filtersJson, permissionFiltersJson, limit, offset, orderBy, refetchTrigger]); // ← Added permissionFiltersJson const refetch = useCallback(() => { hasAttemptedRef.current = false; // Reset to allow refetch @@ -324,5 +376,4 @@ export function useAssetSearch() { }, []); return { results, loading, error, search, clearResults }; -} - +} \ No newline at end of file diff --git a/src/hooks/useUserPermissions.ts b/src/hooks/useUserPermissions.ts new file mode 100644 index 0000000..bc4d701 --- /dev/null +++ b/src/hooks/useUserPermissions.ts @@ -0,0 +1,188 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import apiService from '../services/apiService'; + +interface RestrictionInfo { + field: string; + values: string[]; + count: number; +} + +interface PermissionsState { + isAdmin: boolean; + restrictions: Record; + permissionFilters: Record; + targetDoctype: string; + loading: boolean; + error: string | null; +} + +/** + * Generic hook for user permissions - works with ANY doctype + * + * Usage: + * const { permissionFilters, restrictions } = useUserPermissions('Asset'); + * const { permissionFilters, restrictions } = useUserPermissions('Work Order'); + * const { permissionFilters, restrictions } = useUserPermissions('Project'); + */ +export const useUserPermissions = (targetDoctype: string = 'Asset') => { + const [state, setState] = useState({ + isAdmin: false, + restrictions: {}, + permissionFilters: {}, + targetDoctype, + loading: true, + error: null + }); + + const fetchPermissions = useCallback(async (doctype?: string) => { + const dt = doctype || targetDoctype; + + try { + setState(prev => ({ ...prev, loading: true, error: null, targetDoctype: dt })); + + const response = await apiService.getPermissionFilters(dt); + + setState({ + isAdmin: response.is_admin, + restrictions: response.restrictions || {}, + permissionFilters: response.filters || {}, + targetDoctype: dt, + loading: false, + error: null + }); + + return response; + } catch (err) { + console.error(`Error fetching permissions for ${dt}:`, err); + setState(prev => ({ + ...prev, + loading: false, + error: err instanceof Error ? err.message : 'Failed to fetch permissions' + })); + return null; + } + }, [targetDoctype]); + + useEffect(() => { + fetchPermissions(); + }, [fetchPermissions]); + + // Get allowed values for a permission type (e.g., "Company", "Location") + const getAllowedValues = useCallback((permissionType: string): string[] => { + return state.restrictions[permissionType]?.values || []; + }, [state.restrictions]); + + // Check if user has restriction on a permission type + const hasRestriction = useCallback((permissionType: string): boolean => { + if (state.isAdmin) return false; + return !!state.restrictions[permissionType]; + }, [state.isAdmin, state.restrictions]); + + // Check if any restrictions exist + const hasAnyRestrictions = useMemo(() => { + return !state.isAdmin && Object.keys(state.restrictions).length > 0; + }, [state.isAdmin, state.restrictions]); + + // Merge user filters with permission filters + const mergeFilters = useCallback((userFilters: Record): Record => { + if (state.isAdmin) return userFilters; + + const merged = { ...userFilters }; + + for (const [field, value] of Object.entries(state.permissionFilters)) { + if (!merged[field]) { + merged[field] = value; + } else if (Array.isArray(value) && value[0] === 'in') { + const permittedValues = value[1] as string[]; + if (typeof merged[field] === 'string' && !permittedValues.includes(merged[field])) { + merged[field] = ['in', []]; // Return empty - value not permitted + } + } + } + + return merged; + }, [state.isAdmin, state.permissionFilters]); + + // Get summary of restrictions for display + const restrictionsList = useMemo(() => { + return Object.entries(state.restrictions).map(([type, info]) => ({ + type, + field: info.field, + values: info.values, + count: info.count + })); + }, [state.restrictions]); + + return { + ...state, + refetch: fetchPermissions, + switchDoctype: fetchPermissions, + getAllowedValues, + hasRestriction, + hasAnyRestrictions, + mergeFilters, + restrictionsList + }; +}; + +/** + * Hook to check access to a specific document + */ +export const useDocumentAccess = (doctype: string | null, docname: string | null) => { + const [hasAccess, setHasAccess] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!doctype || !docname) { + setHasAccess(null); + return; + } + + const check = async () => { + try { + setLoading(true); + const response = await apiService.checkDocumentAccess(doctype, docname); + setHasAccess(response.has_access); + if (!response.has_access && response.error) { + setError(response.error); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to check access'); + setHasAccess(false); + } finally { + setLoading(false); + } + }; + + check(); + }, [doctype, docname]); + + return { hasAccess, loading, error }; +}; + +/** + * Hook to get user's default values + */ +export const useUserDefaults = () => { + const [defaults, setDefaults] = useState>({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetch = async () => { + try { + const response = await apiService.getUserDefaults(); + setDefaults(response.defaults || {}); + } catch (err) { + console.error('Failed to fetch user defaults:', err); + } finally { + setLoading(false); + } + }; + fetch(); + }, []); + + return { defaults, loading, getDefault: (type: string) => defaults[type] }; +}; + +export default useUserPermissions; \ No newline at end of file diff --git a/src/pages/AssetDetail.tsx b/src/pages/AssetDetail.tsx index 2934296..1012e30 100644 --- a/src/pages/AssetDetail.tsx +++ b/src/pages/AssetDetail.tsx @@ -2996,179 +2996,6 @@ const handlePPMPlan = async () => { - - - - {/* Financial Details - Full Width */} - {/*
-
-

- Financial Details -

- - -
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
- - -
- - setFormData({ - ...formData, - calculate_depreciation: e.target.checked, - }) - } - disabled={isFieldDisabled('calculate_depreciation')} - className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" - /> - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Period - - Opening Value - - Depreciation Amount - - Accumulated Depreciation - - Closing Value -
- Year 1 - - - - - - - - - - - - -
- Year 2 - - - - - - - - - - - - -
- Year 3 - - - - - - - - - - - - -
- {!asset && ( -

- Depreciation schedule will be calculated when asset is saved -

- )} -
-
-
*/} ); diff --git a/src/pages/AssetList.tsx b/src/pages/AssetList.tsx index 6754b22..caaccba 100644 --- a/src/pages/AssetList.tsx +++ b/src/pages/AssetList.tsx @@ -1,8 +1,11 @@ import React, { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAssets, useAssetMutations } from '../hooks/useAsset'; -import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownload, FaPrint, FaFileExport, FaTimes, FaFilter, FaChevronDown, FaChevronUp, FaSave, FaStar } from 'react-icons/fa'; +import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownload, FaPrint, FaFileExport, FaTimes, FaFilter, FaChevronDown, FaChevronUp, FaSave, + FaStar, FaLock } from 'react-icons/fa'; import LinkField from '../components/LinkField'; +import { useUserPermissions } from '../hooks/useUserPermissions'; + const AssetList: React.FC = () => { const navigate = useNavigate(); @@ -13,6 +16,18 @@ const AssetList: React.FC = () => { const dropdownRef = useRef(null); const limit = 20; + // ✅ NEW: Get user permissions for Asset doctype + const { + isAdmin, + permissionFilters, + restrictions, + loading: permissionsLoading, + error: permissionsError, + hasRestriction, + getAllowedValues, + hasAnyRestrictions + } = useUserPermissions('Asset'); + // Filter states const [filterAssetId, setFilterAssetId] = useState(''); const [filterCompany, setFilterCompany] = useState(''); @@ -96,7 +111,8 @@ const AssetList: React.FC = () => { filters, limit, page * limit, - 'creation desc' + 'creation desc', + permissionFilters // ← Pass permission filters here ); const { deleteAsset, loading: mutationLoading } = useAssetMutations(); @@ -314,6 +330,106 @@ const AssetList: React.FC = () => { filterLocation || filterDepartment || filterModality || filterDeviceStatus || filterAssetName || filterSerialNumber || searchTerm; + // ✅ NEW: Helper component for restricted select fields + const RestrictedSelect: React.FC<{ + label: string; + permissionType: string; + value: string; + onChange: (val: string) => void; + doctype: string; + placeholder?: string; + filters?: Record; + }> = ({ label, permissionType, value, onChange, doctype, placeholder, filters: linkFilters }) => { + const isRestricted = hasRestriction(permissionType); + const allowedValues = getAllowedValues(permissionType); + + if (isRestricted && allowedValues.length > 0) { + return ( +
+ + + {value && ( + + )} +
+ ); + } + + // Not restricted - show normal LinkField + return ( +
+ + {value && ( + + )} +
+ ); + }; + + // ✅ NEW: Loading state for permissions + if (permissionsLoading) { + return ( +
+
+
+

Loading permissions...

+
+
+ ); + } + + // ✅ NEW: Error state for permissions + if (permissionsError) { + return ( +
+
+

⚠️ Permission Error

+
+

Unable to load user permissions.

+

{permissionsError}

+ +
+
+
+ ); + } + if (loading && page === 0) { return (
@@ -358,6 +474,13 @@ const AssetList: React.FC = () => {

Assets

Total: {totalCount} asset{totalCount !== 1 ? 's' : ''} + {/* ✅ NEW: Show permission indicator */} + {/* {hasAnyRestrictions && ( + + + Filtered by permissions + + )} */}

@@ -380,51 +503,152 @@ const AssetList: React.FC = () => {
{/* Advanced Filter Panel */} -
+ {/* Advanced Filter Panel */} +
{/* Filter Header */} -
-
-
+
+
+ {/* Left Side - Toggle & Title */} +
-

Advanced Filters

+

Filters

{activeFilterCount > 0 && ( - + {activeFilterCount} )}
-
- {/* Quick Filter Buttons */} -
- - + {/* Center - Active Filter Tags (scrollable) */} + {hasActiveFilters && ( +
+
+ {filterAssetId && ( + + Asset ID: {filterAssetId} + + + )} + {filterCompany && ( + + Hospital: {filterCompany} + + + )} + {filterAssetName && ( + + Name: {filterAssetName} + + + )} + {filterSerialNumber && ( + + Serial: {filterSerialNumber} + + + )} + {filterDeviceStatus && ( + + Status: {filterDeviceStatus} + + + )} + {filterLocation && ( + + Location: {filterLocation} + + + )} + {filterDepartment && ( + + Dept: {filterDepartment} + + + )} + {filterModality && ( + + Modality: {filterModality} + + + )} + {filterManufacturer && ( + + Mfr: {filterManufacturer} + + + )} + {filterSupplier && ( + + Supplier: {filterSupplier} + + + )} +
+ )} + {/* Right Side - Action Buttons */} +
{/* Save Filter Button */} {activeFilterCount > 0 && ( )} @@ -443,7 +667,7 @@ const AssetList: React.FC = () => { 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" > - Clear All + Clear )}
@@ -451,11 +675,7 @@ const AssetList: React.FC = () => {
{/* Expandable Filter Content */} -
+ {isFilterExpanded && (
{/* Saved Filter Presets */} {savedFilters.length > 0 && ( @@ -488,16 +708,12 @@ const AssetList: React.FC = () => {
)} - {/* Filter Grid - Organized by Category */} + {/* Filter Grid */}
- {/* Asset Identification Group */}
- {/*

- - Asset Identification -

*/}
-
+ {/* Asset ID */} +
{ {filterAssetId && ( )}
+ {/* Asset Name */}
+ {/* Serial Number */}
-
+ + {/* Hospital */} +
{ {filterCompany && ( )}
+ {/* Device Status */}
-
+ + {/* Location */} +
{ {filterLocation && ( )}
-
+ + {/* Department */} +
{ {filterDepartment && ( )}
-
+ + {/* Modality */} +
{ {filterModality && ( )}
-
+ {/* Manufacturer */} +
{ {filterManufacturer && ( )}
-
+ {/* Supplier */} +
{ {filterSupplier && ( @@ -719,108 +948,8 @@ const AssetList: React.FC = () => {
- - {/* Active Filters Summary */} - {hasActiveFilters && ( -
-
-

- Active Filters ({activeFilterCount}) -

- -
-
- {filterAssetId && ( - - Asset ID: {filterAssetId} - - - )} - {filterCompany && ( - - Hospital: {filterCompany} - - - )} - {filterAssetName && ( - - Name: {filterAssetName} - - - )} - {filterSerialNumber && ( - - Serial: {filterSerialNumber} - - - )} - {filterDeviceStatus && ( - - Status: {filterDeviceStatus} - - - )} - {filterLocation && ( - - Location: {filterLocation} - - - )} - {filterDepartment && ( - - Department: {filterDepartment} - - - )} - {filterModality && ( - - Modality: {filterModality} - - - )} - {filterManufacturer && ( - - Manufacturer: {filterManufacturer} - - - )} - {filterSupplier && ( - - Supplier: {filterSupplier} - - - )} -
-
- )}
-
+ )}
{/* Save Filter Modal */} @@ -1025,6 +1154,11 @@ const AssetList: React.FC = () => { {Math.min((page + 1) * limit, totalCount)} {' '} of {totalCount} results + + {hasAnyRestrictions && ( + (filtered) + )} +