feat(publications): add multi-level year filtering for publications and patents
- Add Year filter dropdown with dynamic record counts in admin console (/console/publications) - Add Year filter dropdown in public category publications page (/publications/[category]) - Comply with AC-44 WCAG 2.1 AA small text contrast rules (text-cobalt-dark) - Verified with E2E test suite (Exit Code 0)
This commit is contained in:
@@ -20,6 +20,8 @@ export default function AdminPublicationsPage() {
|
||||
const [publications, setPublications] = useState<AdminPublication[]>([]);
|
||||
const [patents, setPatents] = useState<AdminPatent[]>([]);
|
||||
const [filterCategory, setFilterCategory] = useState<string>("all");
|
||||
const [filterYear, setFilterYear] = useState<string>("all");
|
||||
const [filterPatentYear, setFilterPatentYear] = useState<string>("all");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
@@ -248,10 +250,44 @@ export default function AdminPublicationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPubs =
|
||||
filterCategory === "all"
|
||||
? publications
|
||||
: publications.filter((p) => p.category === filterCategory);
|
||||
// Extract unique sorted years from publications
|
||||
const pubYears = Array.from(
|
||||
new Set(
|
||||
publications
|
||||
.map((p) => {
|
||||
const match = (p.published_at || "").match(/\b(19\d\d|20\d\d)\b/);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter((y): y is string => Boolean(y))
|
||||
)
|
||||
).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
// Extract unique sorted years from patents
|
||||
const patentYears = Array.from(
|
||||
new Set(
|
||||
patents
|
||||
.map((p) => {
|
||||
const dateStr = p.published_at || p.application_at || p.registration_at || "";
|
||||
const match = dateStr.match(/\b(19\d\d|20\d\d)\b/);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter((y): y is string => Boolean(y))
|
||||
)
|
||||
).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const filteredPubs = publications.filter((p) => {
|
||||
const matchCategory = filterCategory === "all" || p.category === filterCategory;
|
||||
const pubYear = (p.published_at || "").match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
|
||||
const matchYear = filterYear === "all" || pubYear === filterYear;
|
||||
return matchCategory && matchYear;
|
||||
});
|
||||
|
||||
const filteredPatents = patents.filter((p) => {
|
||||
if (filterPatentYear === "all") return true;
|
||||
const dateStr = p.published_at || p.application_at || p.registration_at || "";
|
||||
const patYear = dateStr.match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
|
||||
return patYear === filterPatentYear;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -296,21 +332,58 @@ export default function AdminPublicationsPage() {
|
||||
) : tab === "publications" ? (
|
||||
<div>
|
||||
{/* Filter sub-bar */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-xs font-semibold text-ink/70">Filter:</span>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">All Categories ({publications.length})</option>
|
||||
<option value="intl-journal-conf">
|
||||
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf").length})
|
||||
</option>
|
||||
<option value="domestic-journal-conf">
|
||||
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf").length})
|
||||
</option>
|
||||
</select>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-ink/70">Category:</span>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">All Categories ({publications.length})</option>
|
||||
<option value="intl-journal-conf">
|
||||
International Journals & Conferences ({publications.filter((p) => p.category === "intl-journal-conf").length})
|
||||
</option>
|
||||
<option value="domestic-journal-conf">
|
||||
Domestic Journals & Conferences ({publications.filter((p) => p.category === "domestic-journal-conf").length})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-ink/70">Year:</span>
|
||||
<select
|
||||
value={filterYear}
|
||||
onChange={(e) => setFilterYear(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">All Years ({publications.length})</option>
|
||||
{pubYears.map((yr) => {
|
||||
const count = publications.filter((p) => (p.published_at || "").includes(yr)).length;
|
||||
return (
|
||||
<option key={yr} value={yr}>
|
||||
{yr} ({count})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(filterCategory !== "all" || filterYear !== "all") && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterCategory("all");
|
||||
setFilterYear("all");
|
||||
}}
|
||||
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-ink/50 ml-auto">
|
||||
Showing {filteredPubs.length} of {publications.length} records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
@@ -379,25 +452,64 @@ export default function AdminPublicationsPage() {
|
||||
</div>
|
||||
) : (
|
||||
/* Patents Table */
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4 w-28">Date</th>
|
||||
<th className="p-4">Title & Inventors</th>
|
||||
<th className="p-4">App / Reg Numbers</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{patents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-6 text-center text-ink/60">
|
||||
No patents found.
|
||||
</td>
|
||||
<div>
|
||||
{/* Filter sub-bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-ink/70">Year:</span>
|
||||
<select
|
||||
value={filterPatentYear}
|
||||
onChange={(e) => setFilterPatentYear(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt"
|
||||
>
|
||||
<option value="all">All Years ({patents.length})</option>
|
||||
{patentYears.map((yr) => {
|
||||
const count = patents.filter((p) => {
|
||||
const d = p.published_at || p.application_at || p.registration_at || "";
|
||||
return d.includes(yr);
|
||||
}).length;
|
||||
return (
|
||||
<option key={yr} value={yr}>
|
||||
{yr} ({count})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filterPatentYear !== "all" && (
|
||||
<button
|
||||
onClick={() => setFilterPatentYear("all")}
|
||||
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||
>
|
||||
Reset filter
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-ink/50 ml-auto">
|
||||
Showing {filteredPatents.length} of {patents.length} records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-paper border border-line rounded-lg overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-ivory border-b border-line text-xs font-semibold text-ink/70 uppercase">
|
||||
<th className="p-4 w-28">Date</th>
|
||||
<th className="p-4">Title & Inventors</th>
|
||||
<th className="p-4">App / Reg Numbers</th>
|
||||
<th className="p-4 text-right">Actions</th>
|
||||
</tr>
|
||||
) : (
|
||||
patents.map((p) => (
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{filteredPatents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-6 text-center text-ink/60">
|
||||
No patents found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredPatents.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-ivory/50">
|
||||
<td className="p-4 text-xs font-mono text-ink/80">{p.published_at}</td>
|
||||
<td className="p-4">
|
||||
@@ -432,6 +544,7 @@ export default function AdminPublicationsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pub Modal */}
|
||||
|
||||
Reference in New Issue
Block a user