Added user Permissions logic and Updated filters

This commit is contained in:
Duradundi Hadimani 2025-11-26 20:11:28 +05:30
parent 39e49543e3
commit a0124f24f6
7 changed files with 619 additions and 442 deletions

View File

@ -167,109 +167,3 @@ const LinkField: React.FC<LinkFieldProps> = ({
};
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<string, any>
// }
// const LinkField: React.FC<LinkFieldProps> = ({
// 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<HTMLDivElement>(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 (
// <div ref={containerRef} className="relative w-full mb-4">
// <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
// <input
// type="text"
// value={value}
// placeholder={placeholder || `Select ${label}`}
// disabled={disabled}
// 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 disabled:bg-gray-100 dark:disabled:bg-gray-700
// bg-white dark:bg-gray-700 text-gray-900 dark:text-white`}
// onFocus={() => !disabled && setDropdownOpen(true)}
// onChange={(e) => {
// const text = e.target.value;
// setSearchText(text);
// searchLink(text);
// onChange(text);
// }}
// />
// {isDropdownOpen && searchResults.length > 0 && !disabled && (
// <ul className="absolute z-50 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
// rounded-md mt-1 max-h-48 overflow-auto w-full shadow-lg">
// {searchResults.map((item, idx) => (
// <li
// key={idx}
// onClick={() => {
// 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 && (
// <span className="text-gray-600 dark:text-gray-300 text-xs ml-2">{item.description}</span>
// )}
// </li>
// ))}
// </ul>
// )}
// </div>
// );
// };
// export default LinkField;

View File

@ -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

View File

@ -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<string, any>
): 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<string, any> = {} // ← NEW: Permission filters parameter
) {
const [assets, setAssets] = useState<Asset[]>([]);
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 };
}
}

View File

@ -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<string, RestrictionInfo>;
permissionFilters: Record<string, any>;
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<PermissionsState>({
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<string, any>): Record<string, any> => {
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<boolean | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<Record<string, string>>({});
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;

View File

@ -2996,179 +2996,6 @@ const handlePPMPlan = async () => {
</div>
</div>
</div>
{/* Financial Details - Full Width */}
{/* <div className="mt-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<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">
Financial Details
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Depreciation Method
</label>
<select
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"
>
<option value="">Straight Line</option>
<option value="Double Declining Balance">Double Declining Balance</option>
<option value="Written Down Value">Written Down Value</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Depreciation Rate (%)
</label>
<input
type="number"
step="0.01"
placeholder="0.00"
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>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Annual Rate
</label>
<input
type="number"
step="0.01"
placeholder="0.00"
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>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Value
</label>
<input
type="number"
step="0.01"
placeholder="0.00"
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 className="flex items-center mb-6">
<input
id="calculate_depreciation"
type="checkbox"
checked={formData.calculate_depreciation || false}
onChange={(e) =>
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"
/>
<label
htmlFor="calculate_depreciation"
className="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
Calculate Depreciation
</label>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-300 dark:border-gray-600">
<thead>
<tr className="bg-gray-100 dark:bg-gray-700">
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300">
Period
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300">
Opening Value
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300">
Depreciation Amount
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300">
Accumulated Depreciation
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300">
Closing Value
</th>
</tr>
</thead>
<tbody>
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
Year 1
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
</tr>
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
Year 2
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
</tr>
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
Year 3
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-xs text-gray-700 dark:text-gray-300">
-
</td>
</tr>
</tbody>
</table>
{!asset && (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-center">
Depreciation schedule will be calculated when asset is saved
</p>
)}
</div>
</div>
</div> */}
</form>
</div>
);

View File

@ -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<HTMLDivElement>(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<string, any>;
}> = ({ label, permissionType, value, onChange, doctype, placeholder, filters: linkFilters }) => {
const isRestricted = hasRestriction(permissionType);
const allowedValues = getAllowedValues(permissionType);
if (isRestricted && allowedValues.length > 0) {
return (
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5 flex items-center gap-1">
{label}
<FaLock size={8} className="text-amber-500" title={`Restricted to ${allowedValues.length} value(s)`} />
</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-2 py-1 text-xs border border-amber-300 dark:border-amber-600 rounded focus:outline-none focus:ring-2 focus:ring-amber-500 bg-amber-50 dark:bg-amber-900/20 text-gray-900 dark:text-white"
>
<option value="">All Allowed ({allowedValues.length})</option>
{allowedValues.map(val => (
<option key={val} value={val}>{val}</option>
))}
</select>
{value && (
<button
onClick={() => onChange('')}
className="absolute right-8 top-6 text-gray-400 hover:text-red-500 transition-colors"
>
<FaTimes size={10} />
</button>
)}
</div>
);
}
// Not restricted - show normal LinkField
return (
<div className="relative">
<LinkField
label={label}
doctype={doctype}
value={value}
onChange={onChange}
placeholder={placeholder || `Select ${label}`}
disabled={false}
compact={true}
filters={linkFilters}
/>
{value && (
<button
onClick={() => onChange('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors"
>
<FaTimes size={10} />
</button>
)}
</div>
);
};
// ✅ NEW: Loading state for permissions
if (permissionsLoading) {
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 permissions...</p>
</div>
</div>
);
}
// ✅ NEW: Error state for permissions
if (permissionsError) {
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"> Permission Error</h2>
<div className="text-red-700 dark:text-red-400 space-y-3">
<p><strong>Unable to load user permissions.</strong></p>
<p>{permissionsError}</p>
<button
onClick={() => window.location.reload()}
className="mt-2 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded"
>
Retry
</button>
</div>
</div>
</div>
);
}
if (loading && page === 0) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
@ -358,6 +474,13 @@ const AssetList: React.FC = () => {
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">Assets</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Total: {totalCount} asset{totalCount !== 1 ? 's' : ''}
{/* ✅ NEW: Show permission indicator */}
{/* {hasAnyRestrictions && (
<span className="ml-2 inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<FaLock size={10} />
Filtered by permissions
</span>
)} */}
</p>
</div>
<div className="flex gap-3">
@ -380,51 +503,152 @@ const AssetList: React.FC = () => {
</div>
{/* Advanced Filter Panel */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4 overflow-hidden">
{/* Advanced Filter Panel */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
{/* Filter Header */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<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">
{/* Left Side - Toggle & Title */}
<div className="flex items-center gap-3 flex-shrink-0">
<button
onClick={() => setIsFilterExpanded(!isFilterExpanded)}
className="text-white hover:bg-blue-600 dark:hover:bg-blue-700 p-2 rounded-lg transition-all"
className="text-white hover:bg-white/20 p-2 rounded-lg transition-all"
>
{isFilterExpanded ? <FaChevronUp size={14} /> : <FaChevronDown size={14} />}
</button>
<div className="flex items-center gap-2">
<FaFilter className="text-white" size={16} />
<h3 className="text-white font-semibold text-sm">Advanced Filters</h3>
<h3 className="text-white font-semibold text-sm">Filters</h3>
</div>
{activeFilterCount > 0 && (
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold animate-pulse">
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
{activeFilterCount}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Quick Filter Buttons */}
<div className="flex gap-1">
<button
onClick={() => applyQuickFilter('active')}
className="px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1"
title="Active Assets"
>
<span></span>
<span className="hidden lg:inline">Active</span>
</button>
<button
onClick={() => applyQuickFilter('down')}
className="px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1"
title="Down Assets"
>
<span></span>
<span className="hidden lg:inline">Down</span>
</button>
{/* Center - Active Filter Tags (scrollable) */}
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
<div className="flex items-center gap-2 py-1">
{filterAssetId && (
<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">Asset ID:</span> {filterAssetId}
<button
onClick={(e) => { e.stopPropagation(); setFilterAssetId(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterCompany && (
<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> {filterCompany}
<button
onClick={(e) => { e.stopPropagation(); setFilterCompany(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterAssetName && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-cyan-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Name:</span> {filterAssetName}
<button
onClick={(e) => { e.stopPropagation(); setFilterAssetName(''); setTempAssetName(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterSerialNumber && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-lime-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Serial:</span> {filterSerialNumber}
<button
onClick={(e) => { e.stopPropagation(); setFilterSerialNumber(''); setTempSerialNumber(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterDeviceStatus && (
<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">Status:</span> {filterDeviceStatus}
<button
onClick={(e) => { e.stopPropagation(); setFilterDeviceStatus(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterLocation && (
<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">Location:</span> {filterLocation}
<button
onClick={(e) => { e.stopPropagation(); setFilterLocation(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterDepartment && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-yellow-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Dept:</span> {filterDepartment}
<button
onClick={(e) => { e.stopPropagation(); setFilterDepartment(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterModality && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-pink-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Modality:</span> {filterModality}
<button
onClick={(e) => { e.stopPropagation(); setFilterModality(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterManufacturer && (
<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">Mfr:</span> {filterManufacturer}
<button
onClick={(e) => { e.stopPropagation(); setFilterManufacturer(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterSupplier && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-teal-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Supplier:</span> {filterSupplier}
<button
onClick={(e) => { e.stopPropagation(); setFilterSupplier(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
</div>
</div>
)}
{/* Right Side - Action Buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Save Filter Button */}
{activeFilterCount > 0 && (
<button
@ -432,7 +656,7 @@ const AssetList: React.FC = () => {
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} />
Save
<span className="hidden sm:inline">Save</span>
</button>
)}
@ -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"
>
<FaTimes size={12} />
Clear All
<span className="hidden sm:inline">Clear</span>
</button>
)}
</div>
@ -451,11 +675,7 @@ const AssetList: React.FC = () => {
</div>
{/* Expandable Filter Content */}
<div
className={`transition-all duration-300 ease-in-out ${
isFilterExpanded ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0'
} overflow-hidden`}
>
{isFilterExpanded && (
<div className="p-4">
{/* Saved Filter Presets */}
{savedFilters.length > 0 && (
@ -488,16 +708,12 @@ const AssetList: React.FC = () => {
</div>
)}
{/* Filter Grid - Organized by Category */}
{/* Filter Grid */}
<div className="space-y-5">
{/* Asset Identification Group */}
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
{/* <h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<span className="w-1 h-4 bg-blue-500 rounded-full"></span>
Asset Identification
</h4> */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
<div className="relative">
{/* Asset ID */}
<div className="relative z-[60]">
<LinkField
label="Asset ID"
doctype="Asset"
@ -510,13 +726,14 @@ const AssetList: React.FC = () => {
{filterAssetId && (
<button
onClick={() => setFilterAssetId('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors"
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
</button>
)}
</div>
{/* Asset Name */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
Asset Name
@ -547,6 +764,7 @@ const AssetList: React.FC = () => {
)}
</div>
{/* Serial Number */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
Serial Number
@ -576,7 +794,9 @@ const AssetList: React.FC = () => {
</button>
)}
</div>
<div className="relative">
{/* Hospital */}
<div className="relative z-[59]">
<LinkField
label="Hospital"
doctype="Company"
@ -590,13 +810,14 @@ const AssetList: React.FC = () => {
{filterCompany && (
<button
onClick={() => setFilterCompany('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors"
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
</button>
)}
</div>
{/* Device Status */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
Device Status
@ -619,7 +840,9 @@ const AssetList: React.FC = () => {
</button>
)}
</div>
<div className="relative">
{/* Location */}
<div className="relative z-[58]">
<LinkField
label="Location"
doctype="Location"
@ -632,13 +855,15 @@ const AssetList: React.FC = () => {
{filterLocation && (
<button
onClick={() => setFilterLocation('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors"
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">
{/* Department */}
<div className="relative z-[57]">
<LinkField
label="Department"
doctype="Department"
@ -651,13 +876,15 @@ const AssetList: React.FC = () => {
{filterDepartment && (
<button
onClick={() => setFilterDepartment('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors"
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">
{/* Modality */}
<div className="relative z-[56]">
<LinkField
label="Modality"
doctype="Modality"
@ -670,14 +897,15 @@ const AssetList: React.FC = () => {
{filterModality && (
<button
onClick={() => setFilterModality('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors"
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">
{/* Manufacturer */}
<div className="relative z-[55]">
<LinkField
label="Manufacturer"
doctype="Manufacturer"
@ -690,14 +918,15 @@ const AssetList: React.FC = () => {
{filterManufacturer && (
<button
onClick={() => setFilterManufacturer('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors"
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">
{/* Supplier */}
<div className="relative z-[54]">
<LinkField
label="Supplier"
doctype="Supplier"
@ -710,7 +939,7 @@ const AssetList: React.FC = () => {
{filterSupplier && (
<button
onClick={() => setFilterSupplier('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors"
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
</button>
@ -719,108 +948,8 @@ const AssetList: React.FC = () => {
</div>
</div>
</div>
{/* Active Filters Summary */}
{hasActiveFilters && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300">
Active Filters ({activeFilterCount})
</h4>
<button
onClick={handleClearFilters}
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 font-medium flex items-center gap-1"
>
<FaTimes size={10} />
Clear All
</button>
</div>
<div className="flex flex-wrap gap-2">
{filterAssetId && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-blue-100 to-blue-200 dark:from-blue-900 dark:to-blue-800 text-blue-800 dark:text-blue-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Asset ID:</span> {filterAssetId}
<button onClick={() => setFilterAssetId('')} className="hover:text-blue-600 dark:hover:text-blue-400">
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterCompany && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-green-100 to-green-200 dark:from-green-900 dark:to-green-800 text-green-800 dark:text-green-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Hospital:</span> {filterCompany}
<button onClick={() => setFilterCompany('')} className="hover:text-green-600">
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterAssetName && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-cyan-100 to-cyan-200 dark:from-cyan-900 dark:to-cyan-800 text-cyan-800 dark:text-cyan-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Name:</span> {filterAssetName}
<button onClick={() => { setFilterAssetName(''); setTempAssetName(''); }}>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterSerialNumber && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-lime-100 to-lime-200 dark:from-lime-900 dark:to-lime-800 text-lime-800 dark:text-lime-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Serial:</span> {filterSerialNumber}
<button onClick={() => { setFilterSerialNumber(''); setTempSerialNumber(''); }}>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterDeviceStatus && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-orange-100 to-orange-200 dark:from-orange-900 dark:to-orange-800 text-orange-800 dark:text-orange-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Status:</span> {filterDeviceStatus}
<button onClick={() => setFilterDeviceStatus('')}>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterLocation && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-purple-100 to-purple-200 dark:from-purple-900 dark:to-purple-800 text-purple-800 dark:text-purple-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Location:</span> {filterLocation}
<button onClick={() => setFilterLocation('')}>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterDepartment && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-yellow-100 to-yellow-200 dark:from-yellow-900 dark:to-yellow-800 text-yellow-800 dark:text-yellow-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Department:</span> {filterDepartment}
<button onClick={() => setFilterDepartment('')}>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterModality && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-pink-100 to-pink-200 dark:from-pink-900 dark:to-pink-800 text-pink-800 dark:text-pink-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Modality:</span> {filterModality}
<button onClick={() => setFilterModality('')}>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterManufacturer && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-indigo-100 to-indigo-200 dark:from-indigo-900 dark:to-indigo-800 text-indigo-800 dark:text-indigo-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Manufacturer:</span> {filterManufacturer}
<button onClick={() => setFilterManufacturer('')}>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterSupplier && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-teal-100 to-teal-200 dark:from-teal-900 dark:to-teal-800 text-teal-800 dark:text-teal-200 rounded-full text-[10px] font-medium shadow-sm">
<span className="font-semibold">Supplier:</span> {filterSupplier}
<button onClick={() => setFilterSupplier('')}>
<FaTimes className="text-[9px]" />
</button>
</span>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* Save Filter Modal */}
@ -1025,6 +1154,11 @@ const AssetList: React.FC = () => {
{Math.min((page + 1) * limit, totalCount)}
</span>{' '}
of <span className="font-medium">{totalCount}</span> results
{hasAnyRestrictions && (
<span className="ml-1 text-amber-600 dark:text-amber-400">(filtered)</span>
)}
</div>
<div className="flex gap-2">
<button

View File

@ -121,6 +121,47 @@ interface RequestOptions {
body?: any;
}
// USER PERMISSION INTERFACES
interface RestrictionInfo {
field: string;
values: string[];
count: number;
}
interface PermissionFiltersResponse {
is_admin: boolean;
filters: Record<string, any>;
restrictions: Record<string, RestrictionInfo>;
target_doctype: string;
user?: string;
total_restrictions?: number;
warning?: string;
}
interface AllowedValuesResponse {
is_admin: boolean;
allowed_values: string[];
default_value?: string | null;
has_restriction: boolean;
allow_doctype: string;
}
interface DocumentAccessResponse {
has_access: boolean;
is_admin?: boolean;
no_restrictions?: boolean;
error?: string;
denied_by?: string;
field?: string;
document_value?: string;
allowed_values?: string[];
}
interface UserDefaultsResponse {
is_admin: boolean;
defaults: Record<string, string>;
}
class ApiService {
private baseURL: string;
private endpoints: Record<string, string>;
@ -381,6 +422,39 @@ class ApiService {
});
}
// USER PERMISSION METHODS
async getUserPermissions(userId?: string): Promise<any> {
const params = userId ? `?user=${encodeURIComponent(userId)}` : '';
return this.apiCall(`${this.endpoints.GET_USER_PERMISSIONS}${params}`);
}
async getPermissionFilters(targetDoctype: string, userId?: string): Promise<PermissionFiltersResponse> {
const params = new URLSearchParams({ target_doctype: targetDoctype });
if (userId) params.append('user', userId);
return this.apiCall<PermissionFiltersResponse>(`${this.endpoints.GET_PERMISSION_FILTERS}?${params}`);
}
async getAllowedValues(allowDoctype: string, userId?: string): Promise<AllowedValuesResponse> {
const params = new URLSearchParams({ allow_doctype: allowDoctype });
if (userId) params.append('user', userId);
return this.apiCall<AllowedValuesResponse>(`${this.endpoints.GET_ALLOWED_VALUES}?${params}`);
}
async checkDocumentAccess(doctype: string, docname: string, userId?: string): Promise<DocumentAccessResponse> {
const params = new URLSearchParams({ doctype, docname });
if (userId) params.append('user', userId);
return this.apiCall<DocumentAccessResponse>(`${this.endpoints.CHECK_DOCUMENT_ACCESS}?${params}`);
}
async getConfiguredDoctypes(): Promise<any> {
return this.apiCall(this.endpoints.GET_CONFIGURED_DOCTYPES);
}
async getUserDefaults(userId?: string): Promise<UserDefaultsResponse> {
const params = userId ? `?user=${encodeURIComponent(userId)}` : '';
return this.apiCall<UserDefaultsResponse>(`${this.endpoints.GET_USER_DEFAULTS}${params}`);
}
// Utility Methods
isAuthenticated(): boolean {
// Check if user is authenticated (implement based on your auth strategy)