chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
|
||||
|
||||
// SVG fill colors for the bar chart (work in both light and dark contexts)
|
||||
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
|
||||
|
||||
// Tailwind class sets per proficiency level (1–5), dark-mode aware
|
||||
const PROFICIENCY_CLASSES = [
|
||||
"bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500",
|
||||
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600",
|
||||
"bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500",
|
||||
"bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500",
|
||||
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500",
|
||||
];
|
||||
|
||||
function proficiencyClasses(level: number): string {
|
||||
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
|
||||
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
|
||||
}
|
||||
|
||||
function ProficiencyBadge({ value }: { value: number }) {
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
|
||||
{value} {PROFICIENCY_LABELS[value] ?? ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SkillRule = { skill: string; minProficiency: number };
|
||||
|
||||
export function SkillsAnalytics() {
|
||||
const datalistId = useId();
|
||||
|
||||
// ── Skill table filters ──────────────────────────────────────────────────
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("");
|
||||
const [minCount, setMinCount] = useState<number>(1);
|
||||
const [skillSearch, setSkillSearch] = useState<string>("");
|
||||
|
||||
// ── People Finder ────────────────────────────────────────────────────────
|
||||
const [rules, setRules] = useState<SkillRule[]>([]);
|
||||
const [operator, setOperator] = useState<"AND" | "OR">("AND");
|
||||
const [peopleChapter, setPeopleChapter] = useState<string>("");
|
||||
|
||||
const { data, isLoading, error } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const activeRules = rules.filter((r) => r.skill.trim().length > 0);
|
||||
const { data: peopleResults, isFetching: peopleFetching } =
|
||||
trpc.resource.searchBySkills.useQuery(
|
||||
{
|
||||
rules: activeRules,
|
||||
operator,
|
||||
...(peopleChapter ? { chapter: peopleChapter } : {}),
|
||||
},
|
||||
{
|
||||
enabled: activeRules.length > 0,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
function addRule() {
|
||||
setRules((prev) => [...prev, { skill: "", minProficiency: 1 }]);
|
||||
}
|
||||
|
||||
function removeRule(idx: number) {
|
||||
setRules((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function updateRule(idx: number, patch: Partial<SkillRule>) {
|
||||
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
function exportXlsx() {
|
||||
if (!data) return;
|
||||
const rows = data.aggregated.map((e) => ({
|
||||
Skill: e.skill,
|
||||
Category: e.category,
|
||||
"# Resources": e.count,
|
||||
"Avg Proficiency": e.avgProficiency,
|
||||
Chapters: e.chapters.join(", "),
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Skills");
|
||||
XLSX.writeFile(wb, `skills-analytics-${Date.now()}.xlsx`);
|
||||
}
|
||||
|
||||
const allSkillNames = (data?.aggregated ?? []).map((e) => e.skill);
|
||||
|
||||
const filtered = (data?.aggregated ?? []).filter((e) => {
|
||||
if (categoryFilter && e.category !== categoryFilter) return false;
|
||||
if (e.count < minCount) return false;
|
||||
if (skillSearch && !e.skill.toLowerCase().includes(skillSearch.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const { sorted: sortedSkills, sortField: skillSortField, sortDir: skillSortDir, toggle: skillToggle } = useTableSort(filtered);
|
||||
const top20 = filtered.slice(0, 20);
|
||||
const gapSkills = (data?.aggregated ?? []).filter((e) => e.count < 3 && e.avgProficiency >= 3);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-64" />
|
||||
<div className="h-64 bg-gray-100 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Skills Analytics</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{data?.totalResources} active resources · {data?.totalSkillEntries} distinct skills
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportXlsx}
|
||||
disabled={!data}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
↓ Export XLS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── People Finder ──────────────────────────────────────────────────── */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800">People Finder</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
Find resources that match skill criteria
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<datalist id={datalistId}>
|
||||
{allSkillNames.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 flex-wrap">
|
||||
{/* AND / OR connector label */}
|
||||
{idx > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperator((op) => (op === "AND" ? "OR" : "AND"))}
|
||||
className="w-12 text-center text-xs font-bold px-2 py-1.5 rounded-lg border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 transition-colors shrink-0"
|
||||
>
|
||||
{operator}
|
||||
</button>
|
||||
)}
|
||||
{idx === 0 && (
|
||||
<span className="w-12 text-center text-xs font-medium text-gray-400 shrink-0">
|
||||
knows
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skill input */}
|
||||
<input
|
||||
type="text"
|
||||
list={datalistId}
|
||||
placeholder="Skill name…"
|
||||
value={rule.skill}
|
||||
onChange={(e) => updateRule(idx, { skill: e.target.value })}
|
||||
className="flex-1 min-w-40 px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
|
||||
{/* Min proficiency selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-400 shrink-0">min.</span>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200">
|
||||
{[1, 2, 3, 4, 5].map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
title={PROFICIENCY_LABELS[lvl]}
|
||||
onClick={() => updateRule(idx, { minProficiency: lvl })}
|
||||
className={`px-2 py-1 text-xs font-medium transition-colors ${
|
||||
rule.minProficiency === lvl
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(idx)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors p-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add rule + chapter filter row */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-dashed border-brand-300 text-brand-600 hover:bg-brand-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add skill rule
|
||||
</button>
|
||||
|
||||
{rules.length > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">Match:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperator("AND")}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-l-lg border transition-colors ${
|
||||
operator === "AND"
|
||||
? "bg-brand-600 border-brand-600 text-white"
|
||||
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
All (AND)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperator("OR")}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-r-lg border -ml-px transition-colors ${
|
||||
operator === "OR"
|
||||
? "bg-brand-600 border-brand-600 text-white"
|
||||
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Any (OR)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data?.allChapters ?? []).length > 0 && (
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<span className="text-xs text-gray-500">Chapter:</span>
|
||||
<select
|
||||
value={peopleChapter}
|
||||
onChange={(e) => setPeopleChapter(e.target.value)}
|
||||
className="px-2 py-1.5 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All chapters</option>
|
||||
{(data?.allChapters ?? []).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{activeRules.length > 0 && (
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
{peopleFetching ? (
|
||||
<div className="text-sm text-gray-400 animate-pulse">Searching…</div>
|
||||
) : peopleResults && peopleResults.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">No resources match these criteria.</p>
|
||||
) : peopleResults && peopleResults.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{peopleResults.length} resource{peopleResults.length !== 1 ? "s" : ""} found
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{peopleResults.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/resources/${person.id}`}
|
||||
className="text-sm font-medium text-gray-900 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
{person.displayName}
|
||||
</a>
|
||||
{person.chapter && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-100">
|
||||
{person.chapter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{person.matchedSkills.map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border ${proficiencyClasses(s.proficiency)}`}
|
||||
>
|
||||
{s.skill}
|
||||
<span className="font-semibold">{s.proficiency}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/resources/${person.id}`}
|
||||
className="text-xs text-brand-600 hover:underline shrink-0 mt-0.5"
|
||||
>
|
||||
View →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Skill table filters ────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Fuzzy search */}
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search skills…"
|
||||
value={skillSearch}
|
||||
onChange={(e) => setSkillSearch(e.target.value)}
|
||||
className="pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500 w-52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{(data?.categories ?? []).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600">
|
||||
Min. resources:
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={minCount}
|
||||
onChange={(e) => setMinCount(Math.max(1, parseInt(e.target.value, 10) || 1))}
|
||||
className="w-16 px-2 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<span className="text-sm text-gray-400">{filtered.length} skills shown</span>
|
||||
|
||||
{(skillSearch || categoryFilter || minCount > 1) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSkillSearch(""); setCategoryFilter(""); setMinCount(1); }}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top 20 Skills Bar Chart */}
|
||||
{top20.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-4">Top Skills by Resource Count</h2>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={top20} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8 }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||
{top20.map((entry) => (
|
||||
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light → dark = low → high)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Gap */}
|
||||
{gapSkills.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-3">
|
||||
Skills Gap
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">high proficiency, few practitioners (<3)</span>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{gapSkills.map((e) => (
|
||||
<button
|
||||
key={e.skill}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 3 }]);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
title="Add to People Finder"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
{e.skill}
|
||||
<span className="text-xs opacity-70">{e.count} person{e.count !== 1 ? "s" : ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">Click a skill to add it to the People Finder above.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<SortableColumnHeader label="Skill" field="skill" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
|
||||
<SortableColumnHeader label="Category" field="category" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
|
||||
<SortableColumnHeader label="Resources" field="count" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
|
||||
<SortableColumnHeader label="Avg Prof." field="avgProficiency" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chapters</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Find</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sortedSkills.map((e) => (
|
||||
<tr key={e.skill} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900">{e.skill}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500">{e.category}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700">{e.count}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<ProficiencyBadge value={e.avgProficiency} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 text-xs">{e.chapters.join(", ") || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
title="Add to People Finder"
|
||||
onClick={() => {
|
||||
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 1 }]);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
className="text-gray-400 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedSkills.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-10 text-gray-400 text-sm">
|
||||
No skills found matching the filters.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user