adds filter in org commission distribution panel
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||||
import { DollarSign, Users } from 'lucide-react';
|
import { DollarSign, Users, Search, ChevronDown, X, Check, FilterX } from 'lucide-react';
|
||||||
|
|
||||||
const COMMISSION_TYPES = {
|
const COMMISSION_TYPES = {
|
||||||
percent_gross: '% of Gross',
|
percent_gross: '% of Gross',
|
||||||
@@ -90,21 +90,241 @@ const CommissionDistributionPanel = ({
|
|||||||
return entries;
|
return entries;
|
||||||
}, [ownerProjects, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults]);
|
}, [ownerProjects, userCommissionOverrides, roleCommissionOverrides, orgMembers, orgCommissionDefaults]);
|
||||||
|
|
||||||
const total = useMemo(() => rows.reduce((s, r) => s + r.amount, 0), [rows]);
|
// ── Filter state ───────────────────────────────────────────────
|
||||||
|
const [nameQuery, setNameQuery] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState('all');
|
||||||
|
const [projectFilter, setProjectFilter] = useState('all');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('all');
|
||||||
|
|
||||||
|
// Searchable project dropdown
|
||||||
|
const [projectOpen, setProjectOpen] = useState(false);
|
||||||
|
const [projectQuery, setProjectQuery] = useState('');
|
||||||
|
const projectBoxRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectOpen) return;
|
||||||
|
const onClick = (e) => {
|
||||||
|
if (projectBoxRef.current && !projectBoxRef.current.contains(e.target)) {
|
||||||
|
setProjectOpen(false);
|
||||||
|
setProjectQuery('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onClick);
|
||||||
|
}, [projectOpen]);
|
||||||
|
|
||||||
|
// Derive available roles, projects, and commission types from current data
|
||||||
|
const roleOptions = useMemo(() => {
|
||||||
|
const set = new Set(rows.map(r => r.role).filter(Boolean));
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const projectOptions = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!map.has(r.projectId)) {
|
||||||
|
map.set(r.projectId, { id: r.projectId, label: r.projectLabel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const typeOptions = useMemo(() => {
|
||||||
|
const set = new Set(rows.map(r => r.commissionType).filter(Boolean));
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const filteredProjectOptions = useMemo(() => {
|
||||||
|
const q = projectQuery.trim().toLowerCase();
|
||||||
|
if (!q) return projectOptions;
|
||||||
|
return projectOptions.filter(p =>
|
||||||
|
(p.label || '').toLowerCase().includes(q) ||
|
||||||
|
(p.id || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [projectOptions, projectQuery]);
|
||||||
|
|
||||||
|
const selectedProjectLabel = useMemo(() => {
|
||||||
|
if (projectFilter === 'all') return 'All Projects';
|
||||||
|
const p = projectOptions.find(p => p.id === projectFilter);
|
||||||
|
return p ? `${p.label} — ${p.id}` : 'All Projects';
|
||||||
|
}, [projectFilter, projectOptions]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
const q = nameQuery.trim().toLowerCase();
|
||||||
|
return rows.filter(r => {
|
||||||
|
if (q && !(r.name || '').toLowerCase().includes(q)) return false;
|
||||||
|
if (roleFilter !== 'all' && r.role !== roleFilter) return false;
|
||||||
|
if (projectFilter !== 'all' && r.projectId !== projectFilter) return false;
|
||||||
|
if (typeFilter !== 'all' && r.commissionType !== typeFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [rows, nameQuery, roleFilter, projectFilter, typeFilter]);
|
||||||
|
|
||||||
|
const total = useMemo(() => filteredRows.reduce((s, r) => s + r.amount, 0), [filteredRows]);
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
nameQuery.trim() !== '' || roleFilter !== 'all' || projectFilter !== 'all' || typeFilter !== 'all';
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setNameQuery('');
|
||||||
|
setRoleFilter('all');
|
||||||
|
setProjectFilter('all');
|
||||||
|
setTypeFilter('all');
|
||||||
|
setProjectQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectClass =
|
||||||
|
"appearance-none w-full pl-3 pr-8 py-2 text-sm rounded-xl bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/40 transition-colors cursor-pointer";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<DollarSign className="text-emerald-500 dark:text-emerald-400 drop-shadow-[0_0_8px_rgba(16,185,129,0.4)]" size={20} />
|
<DollarSign className="text-emerald-500 dark:text-emerald-400 drop-shadow-[0_0_8px_rgba(16,185,129,0.4)]" size={20} />
|
||||||
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">
|
<h3 className="text-sm font-mono font-bold text-zinc-900 dark:text-white tracking-widest uppercase">
|
||||||
Commission Distribution
|
Commission Distribution
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 rounded-full px-2.5 py-0.5">
|
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 rounded-full px-2.5 py-0.5">
|
||||||
{rows.length} entr{rows.length === 1 ? 'y' : 'ies'} across {ownerProjects.length} project{ownerProjects.length !== 1 ? 's' : ''}
|
{hasActiveFilters
|
||||||
|
? `${filteredRows.length} of ${rows.length} entr${rows.length === 1 ? 'y' : 'ies'}`
|
||||||
|
: `${rows.length} entr${rows.length === 1 ? 'y' : 'ies'} across ${ownerProjects.length} project${ownerProjects.length !== 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2.5 p-3 rounded-xl bg-zinc-50 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/5">
|
||||||
|
{/* Search by Name */}
|
||||||
|
<div className="relative">
|
||||||
|
<label htmlFor="commission-name-search" className="sr-only">Search by name</label>
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" size={14} />
|
||||||
|
<input
|
||||||
|
id="commission-name-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name..."
|
||||||
|
value={nameQuery}
|
||||||
|
onChange={(e) => setNameQuery(e.target.value)}
|
||||||
|
className="w-full bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-xl py-2 pl-9 pr-8 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/40"
|
||||||
|
/>
|
||||||
|
{nameQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNameQuery('')}
|
||||||
|
aria-label="Clear name search"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<label htmlFor="commission-role-filter" className="sr-only">Filter by role</label>
|
||||||
|
<select
|
||||||
|
id="commission-role-filter"
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="all">All Roles</option>
|
||||||
|
{roleOptions.map(r => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project searchable dropdown */}
|
||||||
|
<div className="relative" ref={projectBoxRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setProjectOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 pl-3 pr-2 py-2 text-sm rounded-xl bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/40 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="truncate text-left">{selectedProjectLabel}</span>
|
||||||
|
<ChevronDown size={14} className={`shrink-0 text-zinc-400 transition-transform ${projectOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{projectOpen && (
|
||||||
|
<div className="absolute z-30 left-0 right-0 mt-1 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 shadow-xl shadow-black/10 dark:shadow-black/50 overflow-hidden">
|
||||||
|
<div className="relative p-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" size={14} />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
placeholder="Search projects..."
|
||||||
|
value={projectQuery}
|
||||||
|
onChange={(e) => setProjectQuery(e.target.value)}
|
||||||
|
className="w-full bg-zinc-50 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-lg py-1.5 pl-8 pr-2 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-400 dark:placeholder:text-zinc-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 overflow-y-auto custom-scrollbar py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setProjectFilter('all'); setProjectOpen(false); setProjectQuery(''); }}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${projectFilter === 'all' ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10' : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<span>All Projects</span>
|
||||||
|
{projectFilter === 'all' && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />}
|
||||||
|
</button>
|
||||||
|
{filteredProjectOptions.length === 0 ? (
|
||||||
|
<p className="px-3 py-2 text-xs text-zinc-400 dark:text-zinc-500">No projects match "{projectQuery}"</p>
|
||||||
|
) : (
|
||||||
|
filteredProjectOptions.map(p => {
|
||||||
|
const isSelected = p.id === projectFilter;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setProjectFilter(p.id); setProjectOpen(false); setProjectQuery(''); }}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${isSelected ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10' : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="font-semibold">{p.label}</span>
|
||||||
|
<span className="ml-2 text-zinc-400 dark:text-zinc-500 font-mono text-[11px]">{p.id}</span>
|
||||||
|
</span>
|
||||||
|
{isSelected && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commission Type filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<label htmlFor="commission-type-filter" className="sr-only">Filter by commission type</label>
|
||||||
|
<select
|
||||||
|
id="commission-type-filter"
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="all">All Commission Types</option>
|
||||||
|
{typeOptions.map(t => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="sm:col-span-2 lg:col-span-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<FilterX size={12} /> Clear Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="py-10 text-center">
|
<div className="py-10 text-center">
|
||||||
<Users size={28} className="mx-auto mb-3 text-zinc-400" />
|
<Users size={28} className="mx-auto mb-3 text-zinc-400" />
|
||||||
@@ -124,7 +344,22 @@ const CommissionDistributionPanel = ({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{filteredRows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-10 text-center">
|
||||||
|
<Users size={22} className="mx-auto mb-2 text-zinc-400" />
|
||||||
|
<p className="text-sm text-zinc-500">No commission entries match the current filters.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
<FilterX size={12} /> Clear Filters
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{filteredRows.map((row) => (
|
||||||
<tr key={row.key} className="border-b border-zinc-100 dark:border-white/[0.03] hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
|
<tr key={row.key} className="border-b border-zinc-100 dark:border-white/[0.03] hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -160,7 +395,7 @@ const CommissionDistributionPanel = ({
|
|||||||
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10">
|
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10">
|
||||||
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
|
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
|
||||||
<td colSpan={2} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
|
<td colSpan={2} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
|
||||||
Total Commission Amount
|
{hasActiveFilters ? 'Filtered Commission Total' : 'Total Commission Amount'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<span className="font-mono text-sm font-extrabold text-green-600 dark:text-[#39ff14]">
|
<span className="font-mono text-sm font-extrabold text-green-600 dark:text-[#39ff14]">
|
||||||
|
|||||||
Reference in New Issue
Block a user