feat: refine global search RBAC and fix image loading
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m24s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m24s
- Restricted Agent search to Attendances only. - Enabled Super Admin search for Organizations (Tenants). - Fixed user avatar URL construction in search results. - Added Organizations category to search dropdown for Super Admins.
This commit is contained in:
@@ -460,13 +460,14 @@ apiRouter.post('/users/:id/avatar', upload.single('avatar'), async (req, res) =>
|
|||||||
// --- Global Search ---
|
// --- Global Search ---
|
||||||
apiRouter.get('/search', async (req, res) => {
|
apiRouter.get('/search', async (req, res) => {
|
||||||
const { q } = req.query;
|
const { q } = req.query;
|
||||||
if (!q || q.length < 2) return res.json({ members: [], teams: [], attendances: [] });
|
if (!q || q.length < 2) return res.json({ members: [], teams: [], attendances: [], organizations: [] });
|
||||||
|
|
||||||
const queryStr = `%${q}%`;
|
const queryStr = `%${q}%`;
|
||||||
const results = { members: [], teams: [], attendances: [] };
|
const results = { members: [], teams: [], attendances: [], organizations: [] };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Search Members
|
// 1. Search Members (only for roles above agent)
|
||||||
|
if (req.user.role !== 'agent') {
|
||||||
let membersQ = 'SELECT id, name, email, slug, role, team_id, avatar_url FROM users WHERE (name LIKE ? OR email LIKE ?)';
|
let membersQ = 'SELECT id, name, email, slug, role, team_id, avatar_url FROM users WHERE (name LIKE ? OR email LIKE ?)';
|
||||||
const membersParams = [queryStr, queryStr];
|
const membersParams = [queryStr, queryStr];
|
||||||
|
|
||||||
@@ -478,12 +479,10 @@ apiRouter.get('/search', async (req, res) => {
|
|||||||
} else if (req.user.role === 'manager') {
|
} else if (req.user.role === 'manager') {
|
||||||
membersQ += ' AND tenant_id = ? AND (team_id = ? OR id = ?)';
|
membersQ += ' AND tenant_id = ? AND (team_id = ? OR id = ?)';
|
||||||
membersParams.push(req.user.tenant_id, req.user.team_id, req.user.id);
|
membersParams.push(req.user.tenant_id, req.user.team_id, req.user.id);
|
||||||
} else {
|
|
||||||
membersQ += ' AND id = ?';
|
|
||||||
membersParams.push(req.user.id);
|
|
||||||
}
|
}
|
||||||
const [members] = await pool.query(membersQ, membersParams);
|
const [members] = await pool.query(membersQ, membersParams);
|
||||||
results.members = members;
|
results.members = members;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Search Teams (only for roles above agent)
|
// 2. Search Teams (only for roles above agent)
|
||||||
if (req.user.role !== 'agent') {
|
if (req.user.role !== 'agent') {
|
||||||
@@ -503,7 +502,13 @@ apiRouter.get('/search', async (req, res) => {
|
|||||||
results.teams = teams;
|
results.teams = teams;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Search Attendances
|
// 3. Search Organizations (only for super_admin)
|
||||||
|
if (req.user.role === 'super_admin') {
|
||||||
|
const [orgs] = await pool.query('SELECT id, name, slug, status FROM tenants WHERE name LIKE ? OR slug LIKE ?', [queryStr, queryStr]);
|
||||||
|
results.organizations = orgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Search Attendances
|
||||||
let attendancesQ = 'SELECT a.id, a.summary, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.summary LIKE ?';
|
let attendancesQ = 'SELECT a.id, a.summary, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.summary LIKE ?';
|
||||||
const attendancesParams = [queryStr];
|
const attendancesParams = [queryStr];
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
// Search State
|
// Search State
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<{ members: User[], teams: any[], attendances: any[] }>({ members: [], teams: [], attendances: [] });
|
const [searchResults, setSearchResults] = useState<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }>({ members: [], teams: [], attendances: [], organizations: [] });
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [showSearchResults, setShowSearchResults] = useState(false);
|
const [showSearchResults, setShowSearchResults] = useState(false);
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
setShowSearchResults(true);
|
setShowSearchResults(true);
|
||||||
} else {
|
} else {
|
||||||
setSearchResults({ members: [], teams: [], attendances: [] });
|
setSearchResults({ members: [], teams: [], attendances: [], organizations: [] });
|
||||||
setShowSearchResults(false);
|
setShowSearchResults(false);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
@@ -219,11 +219,43 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
{showSearchResults && (
|
{showSearchResults && (
|
||||||
<div className="absolute top-full mt-2 left-0 w-full bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
<div className="absolute top-full mt-2 left-0 w-full bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
<div className="max-h-[480px] overflow-y-auto p-2">
|
<div className="max-h-[480px] overflow-y-auto p-2">
|
||||||
|
{/* Organizations Section (Super Admin only) */}
|
||||||
|
{searchResults.organizations && searchResults.organizations.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Organizações</div>
|
||||||
|
{searchResults.organizations.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/super-admin`);
|
||||||
|
setShowSearchResults(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-brand-yellow border border-zinc-200 dark:border-dark-border">
|
||||||
|
<Hexagon size={18} fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text">{o.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-dark-muted">{o.slug} • {o.status}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Members Section */}
|
{/* Members Section */}
|
||||||
{searchResults.members.length > 0 && (
|
{searchResults.members.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Membros</div>
|
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Membros</div>
|
||||||
{searchResults.members.map(m => (
|
{searchResults.members.map(m => {
|
||||||
|
const backendUrl = import.meta.env.PROD ? '' : 'http://localhost:3001';
|
||||||
|
const avatarSrc = m.avatar_url
|
||||||
|
? (m.avatar_url.startsWith('http') ? m.avatar_url : `${backendUrl}${m.avatar_url}`)
|
||||||
|
: `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`;
|
||||||
|
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -234,7 +266,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
|
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={m.avatar_url?.startsWith('http') ? m.avatar_url : `${import.meta.env.PROD ? '' : 'http://localhost:3001'}${m.avatar_url || ''}`}
|
src={avatarSrc}
|
||||||
alt={m.name}
|
alt={m.name}
|
||||||
className="w-9 h-9 rounded-full border border-zinc-100 dark:border-dark-border object-cover"
|
className="w-9 h-9 rounded-full border border-zinc-100 dark:border-dark-border object-cover"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`; }}
|
onError={(e) => { (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`; }}
|
||||||
@@ -244,7 +276,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
<div className="text-xs text-zinc-500 dark:text-dark-muted">{m.email}</div>
|
<div className="text-xs text-zinc-500 dark:text-dark-muted">{m.email}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -303,7 +336,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{searchResults.members.length === 0 && searchResults.teams.length === 0 && searchResults.attendances.length === 0 && (
|
{searchResults.members.length === 0 && searchResults.teams.length === 0 && searchResults.attendances.length === 0 && (!searchResults.organizations || searchResults.organizations.length === 0) && (
|
||||||
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
|
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
|
||||||
Nenhum resultado encontrado para "{searchQuery}"
|
Nenhum resultado encontrado para "{searchQuery}"
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const getHeaders = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[] }> => {
|
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
||||||
headers: getHeaders()
|
headers: getHeaders()
|
||||||
@@ -26,7 +26,7 @@ export const searchGlobal = async (query: string): Promise<{ members: User[], te
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("API Error (searchGlobal):", error);
|
console.error("API Error (searchGlobal):", error);
|
||||||
return { members: [], teams: [], attendances: [] };
|
return { members: [], teams: [], attendances: [], organizations: [] };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user