ui changes
This commit is contained in:
518
src/pages/admin/Positions.jsx
Normal file
518
src/pages/admin/Positions.jsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowRight, MapPin, Plus, Sparkles, Wallet } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
|
||||
} from '@/components/ds';
|
||||
import { useApplications, useJobPostings } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
|
||||
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
|
||||
import KrowAiChat from '@/components/krow/KrowAiChat';
|
||||
|
||||
/**
|
||||
* Admin Positions — a hiring workspace, one card per role.
|
||||
*
|
||||
* The table this replaces was good at exactly one thing: comparing a single column
|
||||
* down every row. But the question an operator brings to this page is "how is
|
||||
* *this* role doing", and answering it from a table meant reading nine cells and
|
||||
* assembling them yourself. A card can state the funnel, the health and the one
|
||||
* thing to do next as a single thought.
|
||||
*
|
||||
* What keeps it from becoming a wall of decoration:
|
||||
*
|
||||
* - **One insight per card, not every field.** The most actionable finding, chosen
|
||||
* in positionInsights.js, and nothing else.
|
||||
* - **No cards inside cards.** Hierarchy comes from type, dividers and the
|
||||
* progression strip.
|
||||
* - **The drawer holds the depth.** Clicking a card opens the full record over the
|
||||
* grid instead of navigating away, so reviewing seven roles never costs you your
|
||||
* scroll position or your filters.
|
||||
*/
|
||||
|
||||
const SORTS = {
|
||||
attention: {
|
||||
label: 'Needs attention first',
|
||||
/* Health first, then the biggest backlog — so roles that need a person are
|
||||
the ones visible without scrolling. */
|
||||
compare: (a, b) => {
|
||||
const rank = { risk: 0, warning: 1, success: 2, neutral: 3 };
|
||||
return (rank[a.health.tone] - rank[b.health.tone]) || (b.stats.unscreened - a.stats.unscreened);
|
||||
},
|
||||
},
|
||||
applicants: { label: 'Most applicants', compare: (a, b) => b.stats.applied - a.stats.applied },
|
||||
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
|
||||
updated: {
|
||||
label: 'Recently updated',
|
||||
compare: (a, b) => new Date(b.updated_date || b.created_date) - new Date(a.updated_date || a.created_date),
|
||||
},
|
||||
title: { label: 'Title A–Z', compare: (a, b) => a.title.localeCompare(b.title) },
|
||||
};
|
||||
|
||||
const HEALTH_TONE = {
|
||||
success: 'bg-success-muted text-success',
|
||||
warning: 'bg-warning-muted text-warning',
|
||||
risk: 'bg-destructive-muted text-destructive',
|
||||
neutral: 'bg-surface-sunken text-ink-3',
|
||||
};
|
||||
|
||||
/** Hiring health as a small filled chip — it is a state, so it reads as one. */
|
||||
function HealthChip({ health, className }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
|
||||
HEALTH_TONE[health.tone],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
|
||||
{health.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hiring progression: Applied → Screened → Shortlisted → Interviewed → Hired.
|
||||
*
|
||||
* Five labelled counts, each with a proportional bar, rather than one generic
|
||||
* progress bar. A single bar would have to pick one number to stand for the whole
|
||||
* funnel, and the interesting information is precisely *where* the funnel narrows.
|
||||
*/
|
||||
function Progression({ steps, className }) {
|
||||
const entered = steps[0]?.count || 0;
|
||||
|
||||
return (
|
||||
<div className={cn('grid grid-cols-5 gap-1.5', className)} role="group" aria-label="Hiring progression">
|
||||
{steps.map((step) => {
|
||||
const share = entered ? (step.count / entered) * 100 : 0;
|
||||
return (
|
||||
<div key={step.key} className="min-w-0">
|
||||
<div className="h-1 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className={cn('h-full rounded-full', step.count ? 'bg-krow-blue' : 'bg-transparent')}
|
||||
style={{ width: `${Math.max(share, step.count ? 8 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 font-heading text-body-sm font-bold tabular-nums leading-none text-ink-1">
|
||||
{step.count}
|
||||
</p>
|
||||
<p className="truncate text-[10px] leading-tight text-ink-4">{step.label}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One position. The whole card is the target, so there is no hunting for a link. */
|
||||
function PositionCard({ position, onOpen }) {
|
||||
const p = position;
|
||||
|
||||
return (
|
||||
<article
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(p)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpen(p);
|
||||
}
|
||||
}}
|
||||
aria-label={`${p.title}. ${p.health.label}. ${p.stats.applied} applicants. ${p.insight}`}
|
||||
className="group flex cursor-pointer flex-col rounded-xl border border-border bg-surface p-4 text-left shadow-xs
|
||||
transition-[border-color,box-shadow] duration-base ease-out
|
||||
hover:border-krow-blue/40 hover:shadow-md
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{/* Identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
<RoleGlyph category={p.role_category} title={p.title} aiGenerated={p.ai_generated} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-heading text-body font-semibold leading-snug text-ink-1">
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className="truncate text-caption text-ink-3">{p.role_category || 'Uncategorized'}</p>
|
||||
</div>
|
||||
|
||||
<StatusBadge status={p.status} size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Terms */}
|
||||
<dl className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-ink-3">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<dt className="sr-only">Location</dt>
|
||||
<MapPin className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<dd className="truncate">{p.location || 'Location not set'}</dd>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<dt className="sr-only">Pay</dt>
|
||||
<Wallet className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<dd className="truncate">{payLabel(p)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="my-3 h-px bg-border" />
|
||||
|
||||
<Progression steps={p.progression} />
|
||||
|
||||
{/* Health, and the one thing worth knowing. `mb-3.5` rather than a margin on
|
||||
the footer, so the footer is free to take `mt-auto`. */}
|
||||
<div className="mb-3.5 mt-3.5 flex items-start gap-2">
|
||||
<HealthChip health={p.health} className="mt-px" />
|
||||
<p className="min-w-0 flex-1 text-caption leading-relaxed text-ink-2">{p.insight}</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* `mt-auto` pins the footer to the bottom of the card, so a grid row of
|
||||
cards with different insight lengths still has its footers aligned. */}
|
||||
<div className="mt-auto flex items-center justify-between border-t border-border pt-2.5">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-ink-4">
|
||||
{p.ai_generated
|
||||
? <><Sparkles className="h-3 w-3" aria-hidden="true" /> AI Built</>
|
||||
: 'Manual'}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-krow-blue">
|
||||
View position
|
||||
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** The full record, over the grid rather than instead of it. */
|
||||
function PositionDrawer({ position, onClose, onViewCandidates }) {
|
||||
if (!position) return null;
|
||||
const p = position;
|
||||
const s = p.stats;
|
||||
|
||||
const earliest = p.applications.reduce(
|
||||
(oldest, a) => (!oldest || new Date(a.created_date) < new Date(oldest) ? a.created_date : oldest),
|
||||
null
|
||||
);
|
||||
const lastScored = p.applications
|
||||
.filter((a) => a.ai_score > 0)
|
||||
.map((a) => a.updated_date)
|
||||
.sort()
|
||||
.pop();
|
||||
const lastHired = p.applications
|
||||
.filter((a) => a.status === 'hired')
|
||||
.map((a) => a.updated_date)
|
||||
.sort()
|
||||
.pop();
|
||||
|
||||
const timeline = [
|
||||
{ label: 'Position created', date: p.created_date },
|
||||
s.applied && { label: `First of ${s.applied} applications`, date: earliest },
|
||||
s.screened && { label: `${s.screened} screened`, date: lastScored },
|
||||
s.hired && { label: `${s.hired} hired`, date: lastHired },
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={Boolean(p)}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={p.title}
|
||||
description={`${p.role_category || 'Uncategorized'} · ${p.location || 'Location not set'} · ${payLabel(p)}`}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Close</Button>
|
||||
<Button size="sm" onClick={() => onViewCandidates(p)}>
|
||||
View {s.applied} candidate{s.applied === 1 ? '' : 's'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Overview */}
|
||||
<div className="flex items-start gap-3.5">
|
||||
<RoleGlyph category={p.role_category} title={p.title} size="lg" aiGenerated={p.ai_generated} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge status={p.status} size="sm" />
|
||||
<HealthChip health={p.health} />
|
||||
<Badge variant="neutral" size="sm">{p.ai_generated ? 'AI Built' : 'Manual'}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-caption leading-relaxed text-ink-3">
|
||||
{p.health.note} {p.insight}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hiring status */}
|
||||
<section>
|
||||
<SectionTitle title="Hiring status" />
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Applicants', value: s.applied },
|
||||
{ label: 'Shortlisted', value: s.shortlisted },
|
||||
{ label: 'Interviews', value: s.interviews },
|
||||
{ label: 'Hired', value: s.hired },
|
||||
{ label: 'Qualified (70+)', value: s.qualified },
|
||||
{ label: 'Avg score', value: s.avgScore || '—' },
|
||||
].map((m) => (
|
||||
<div key={m.label}>
|
||||
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{m.label}</dt>
|
||||
<dd className="font-heading text-title font-bold tabular-nums text-ink-1">{m.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<span className="text-caption text-ink-2">Screening progress</span>
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{s.coverage}%</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={s.coverage}
|
||||
tone={s.coverage === 100 ? 'success' : s.coverage >= 60 ? 'brand' : 'warning'}
|
||||
size="sm"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-ink-4">
|
||||
{s.applied - s.unscreened} of {s.applied} applicants scored
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="Hiring progression" />
|
||||
<Progression steps={p.progression} />
|
||||
</section>
|
||||
|
||||
{/* Top candidates */}
|
||||
<section>
|
||||
<SectionTitle
|
||||
title="Top candidates"
|
||||
meta={p.topCandidates.length ? `${p.topCandidates.length} scored` : 'None scored'}
|
||||
/>
|
||||
{p.topCandidates.length ? (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{p.topCandidates.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-2.5 px-3 py-2.5">
|
||||
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">
|
||||
{String(c.status).replace(/_/g, ' ')}
|
||||
{c.ai_recommendation ? ` · ${c.ai_recommendation}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{c.ai_score}</p>
|
||||
<p className="text-[10px] text-ink-4">KROW Score</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">
|
||||
Nobody has been screened for this role yet, so there is no ranking to show.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="Hiring timeline" />
|
||||
<ol className="space-y-2">
|
||||
{timeline.map((e, i) => (
|
||||
<li key={i} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
<span className="text-ink-2">{e.label}</span>
|
||||
<span className="shrink-0 text-caption text-ink-4">
|
||||
{e.date
|
||||
? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="Recent activity" />
|
||||
{p.applications.length ? (
|
||||
<ol className="space-y-2">
|
||||
{[...p.applications]
|
||||
.sort((a, b) => new Date(b.updated_date) - new Date(a.updated_date))
|
||||
.slice(0, 5)
|
||||
.map((a) => (
|
||||
<li key={a.id} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
<span className="min-w-0 truncate text-ink-2">
|
||||
<span className="font-medium text-ink-1">{a.applicant_name}</span>
|
||||
{' — '}
|
||||
{a.ai_score > 0 ? `scored ${a.ai_score}` : 'applied, not yet screened'}
|
||||
</span>
|
||||
<span className="shrink-0 text-caption text-ink-4">
|
||||
{new Date(a.updated_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-body-sm text-ink-3">No activity on this position yet.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPositions() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: postings = [], isLoading } = useJobPostings();
|
||||
const { data: applications = [] } = useApplications();
|
||||
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [status, setStatus] = useState(searchParams.get('status') || 'all');
|
||||
const [department, setDepartment] = useState('all');
|
||||
const [location, setLocation] = useState('all');
|
||||
const [sort, setSort] = useState('attention');
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
/* Derived once per position rather than per cell, so the grid does not run the
|
||||
same filter five times per card. */
|
||||
const rows = useMemo(
|
||||
() => postings.map((p) => buildPosition(p, applications)),
|
||||
[postings, applications]
|
||||
);
|
||||
|
||||
const departments = useMemo(
|
||||
() => [...new Set(postings.map((p) => p.role_category).filter(Boolean))].sort(),
|
||||
[postings]
|
||||
);
|
||||
const locations = useMemo(
|
||||
() => [...new Set(postings.map((p) => p.location).filter(Boolean))].sort(),
|
||||
[postings]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows
|
||||
.filter((p) => {
|
||||
if (q && !`${p.title} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
|
||||
if (status !== 'all' && p.status !== status) return false;
|
||||
if (department !== 'all' && p.role_category !== department) return false;
|
||||
if (location !== 'all' && p.location !== location) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(SORTS[sort].compare);
|
||||
}, [rows, search, status, department, location, sort]);
|
||||
|
||||
const isFiltered = Boolean(search) || [status, department, location].some((v) => v !== 'all');
|
||||
const clearFilters = () => {
|
||||
setSearch(''); setStatus('all'); setDepartment('all'); setLocation('all');
|
||||
};
|
||||
|
||||
/* The page summary as a sentence rather than a KPI row: these are counts you
|
||||
read once for orientation, and four cards would outweigh the grid they
|
||||
introduce. */
|
||||
const summary = useMemo(() => {
|
||||
const open = rows.filter((p) => p.status === 'active').length;
|
||||
const sum = (key) => rows.reduce((total, p) => total + p.stats[key], 0);
|
||||
const applied = sum('applied');
|
||||
return [
|
||||
`${open} open position${open === 1 ? '' : 's'}`,
|
||||
`${applied} active candidate${applied === 1 ? '' : 's'}`,
|
||||
`${sum('shortlisted')} shortlisted`,
|
||||
`${sum('hired')} hired`,
|
||||
].join(' · ');
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Positions"
|
||||
subtitle="Manage open roles, hiring demand and position performance."
|
||||
actions={
|
||||
<Button size="sm" shape="rounded" onClick={() => navigate('/positions/new')}>
|
||||
<Plus aria-hidden="true" /> Create Position
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* Controls. Search gets a fixed, sensible measure — wide enough for a role
|
||||
title, not so wide it becomes the page's subject. */}
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div className="w-full lg:w-80">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Search positions" size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect value={status} onChange={setStatus} label="Status" options={[
|
||||
{ value: 'all', label: 'All statuses' }, { value: 'active', label: 'Hiring' },
|
||||
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
]} />
|
||||
<FilterSelect value={department} onChange={setDepartment} label="Department"
|
||||
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
|
||||
<FilterSelect value={location} onChange={setLocation} label="Location"
|
||||
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
|
||||
<FilterSelect value={sort} onChange={setSort} label="Sort"
|
||||
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-caption text-ink-4">
|
||||
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
|
||||
</p>
|
||||
|
||||
{/* Three columns on desktop, two on tablet, one on mobile. */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-64 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((p) => (
|
||||
<PositionCard key={p.id} position={p} onOpen={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={isFiltered ? 'No positions match these filters' : 'No positions yet'}
|
||||
description={
|
||||
isFiltered
|
||||
? 'Try a broader search, or clear the filters to see every role.'
|
||||
: 'Create a position and applications will start appearing here.'
|
||||
}
|
||||
action={
|
||||
isFiltered
|
||||
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
|
||||
: <Button size="sm" onClick={() => navigate('/positions/new')}>Create Position</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PositionDrawer
|
||||
position={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onViewCandidates={(p) => {
|
||||
setSelected(null);
|
||||
if (p.stats.applied) navigate(`/admin/candidates?search=${encodeURIComponent(p.title)}`);
|
||||
else toast.info(`${p.title} has no applicants yet`);
|
||||
}}
|
||||
/>
|
||||
<KrowAiChat page="positions" />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** A compact labelled select — the same control on every Admin management page. */
|
||||
export function FilterSelect({ value, onChange, label, options, className }) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger aria-label={label} className={cn('h-8 w-auto min-w-[8.5rem] text-body-sm', className)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user