Added user Permissions logic and Updated filters
This commit is contained in:
parent
39e49543e3
commit
a0124f24f6
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -325,4 +377,3 @@ export function useAssetSearch() {
|
||||
|
||||
return { results, loading, error, search, clearResults };
|
||||
}
|
||||
|
||||
|
||||
188
src/hooks/useUserPermissions.ts
Normal file
188
src/hooks/useUserPermissions.ts
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user