Compare commits

..

3 Commits

Author SHA1 Message Date
josh 2d9464a6fb Add clear selection button to status multi-select dropdown
Build & Push / Test (client) (push) Successful in 25s
Build & Push / Test (server) (push) Successful in 30s
Build & Push / Build Client (push) Successful in 59s
Build & Push / Build Server (push) Successful in 1m10s
Shows a separator and centered "Clear selection" at the bottom of the dropdown
when any statuses are selected. Clearing shows all tickets regardless of status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 12:25:53 -04:00
josh c6ec47a8fc Replace status tabs with multi-select checkbox dropdown, default to Open + In Progress
Build & Push / Test (client) (push) Successful in 29s
Build & Push / Test (server) (push) Successful in 26s
Build & Push / Build Client (push) Successful in 1m9s
Build & Push / Build Server (push) Successful in 1m17s
Status filtering now supports selecting multiple statuses via a dropdown with checkboxes.
Backend updated to accept comma-separated status values using Prisma `in` operator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:52:13 -04:00
josh cfe7ad56ff Rework tickets filter bar into two-row layout with consistent CTI styling
Build & Push / Test (client) (push) Successful in 27s
Build & Push / Test (server) (push) Successful in 31s
Build & Push / Build Client (push) Successful in 1m5s
Build & Push / Build Server (push) Successful in 1m43s
Split the dense single-row filter bar into two rows: search + saved views on top,
filter selectors below. Fix CTI selectors to use design system tokens instead of
hardcoded dark classes, and upgrade the saved views button with an icon and badge count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:20:29 -04:00
5 changed files with 198 additions and 94 deletions
+52 -5
View File
@@ -4,9 +4,10 @@ interface CTISelectProps {
value: { categoryId: string; typeId: string; itemId: string };
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void;
disabled?: boolean;
compact?: boolean;
}
export default function CTISelect({ value, onChange, disabled }: CTISelectProps) {
export default function CTISelect({ value, onChange, disabled, compact }: CTISelectProps) {
const { data: categories = [] } = useCategories();
const { data: types = [] } = useTypes(value.categoryId || undefined);
const { data: items = [] } = useItems(value.typeId || undefined);
@@ -24,12 +25,58 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
};
const selectClass =
'block w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed';
'block w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
if (compact) {
return (
<div className="flex gap-2">
<select
value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)}
disabled={disabled}
className={selectClass}
>
<option value="">Category...</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<select
value={value.typeId}
onChange={(e) => handleType(e.target.value)}
disabled={disabled || !value.categoryId}
className={selectClass}
>
<option value="">Type...</option>
{types.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
<select
value={value.itemId}
onChange={(e) => handleItem(e.target.value)}
disabled={disabled || !value.typeId}
className={selectClass}
>
<option value="">Item...</option>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-400 mb-1">Category</label>
<label className="block text-xs font-medium text-muted-foreground mb-1">Category</label>
<select
value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)}
@@ -46,7 +93,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1">Type</label>
<label className="block text-xs font-medium text-muted-foreground mb-1">Type</label>
<select
value={value.typeId}
onChange={(e) => handleType(e.target.value)}
@@ -63,7 +110,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1">Item</label>
<label className="block text-xs font-medium text-muted-foreground mb-1">Item</label>
<select
value={value.itemId}
onChange={(e) => handleItem(e.target.value)}
+128 -84
View File
@@ -1,7 +1,9 @@
import { Trash2, Save } from 'lucide-react';
import { Trash2, Save, Check } from 'lucide-react';
import CTISelect from '../../components/CTISelect';
import type { TicketStatus, User } from '../../types';
import { TICKET_STATUSES } from '../../../../shared/schemas/enums';
import type { User } from '../../types';
import type { SavedView } from '../../../../shared/types';
import { STATUS_LABELS } from '../../../../shared/constants/labels';
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,17 +12,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All' },
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'CLOSED', label: 'Closed' },
];
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
interface TicketFiltersProps {
status: TicketStatus | '';
status: string;
severity: string;
assigneeId: string;
categoryId: string;
@@ -62,91 +58,43 @@ export default function TicketFilters({
total,
isFetching,
}: TicketFiltersProps) {
const selectedStatuses = status ? status.split(',') : [];
const toggleStatus = (s: string) => {
const next = selectedStatuses.includes(s)
? selectedStatuses.filter((v) => v !== s)
: [...selectedStatuses, s];
onUpdateParam('status', next.length > 0 ? next.join(',') : null);
};
const statusLabel =
selectedStatuses.length === 0 || selectedStatuses.length === TICKET_STATUSES.length
? 'All statuses'
: selectedStatuses.map((s) => STATUS_LABELS[s] ?? s).join(', ');
return (
<>
{/* Status tabs */}
<div className="flex items-center border-b border-border mb-4 -mx-4 px-4 overflow-x-auto">
{STATUS_TABS.map((tab) => (
<button
key={tab.value}
onClick={() => onUpdateParam('status', tab.value || null)}
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
status === tab.value
? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Filter bar */}
<div className="flex gap-2 mb-4 flex-wrap items-center">
{/* Row 1: Search + saved views + result count */}
<div className="flex gap-2 mb-2 items-center">
<input
type="search"
value={searchInput}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search title, overview, ID…"
className="flex-1 min-w-48 max-w-xs px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
className="flex-1 min-w-48 max-w-sm px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<select
value={severity}
onChange={(e) => onUpdateParam('severity', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
<select
value={assigneeId}
onChange={(e) => onUpdateParam('assigneeId', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All assignees</option>
{authUser && (
<option value={authUser.id}>Me ({authUser.displayName})</option>
)}
{users
.filter((u) => u.id !== authUser?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
<div className="min-w-56">
<CTISelect
value={{ categoryId, typeId, itemId }}
onChange={(cti) => {
onSetParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('page');
if (cti.categoryId) next.set('categoryId', cti.categoryId);
else next.delete('categoryId');
if (cti.typeId) next.set('typeId', cti.typeId);
else next.delete('typeId');
if (cti.itemId) next.set('itemId', cti.itemId);
else next.delete('itemId');
return next;
});
}}
/>
</div>
{/* Saved views */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-3 py-1.5 rounded-md border border-input bg-background text-sm hover:bg-accent">
<Button variant="outline" size="sm" className="gap-1.5">
<Save size={14} />
Saved views
</button>
{savedViews.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px] leading-4">
{savedViews.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Saved views</DropdownMenuLabel>
@@ -191,6 +139,102 @@ export default function TicketFilters({
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
</div>
</div>
{/* Row 2: Filter selectors */}
<div className="flex gap-2 mb-4 items-center flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5 text-sm font-normal">
{statusLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{TICKET_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onSelect={(e) => {
e.preventDefault();
toggleStatus(s);
}}
className="gap-2"
>
<div className="flex h-4 w-4 items-center justify-center rounded-sm border border-input">
{selectedStatuses.includes(s) && <Check size={12} />}
</div>
{STATUS_LABELS[s] ?? s}
</DropdownMenuItem>
))}
{selectedStatuses.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
onSetParams((prev) => {
const next = new URLSearchParams(prev);
next.set('status', '');
next.delete('page');
return next;
});
}}
className="justify-center text-xs text-muted-foreground"
>
Clear selection
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<select
value={severity}
onChange={(e) => onUpdateParam('severity', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
<select
value={assigneeId}
onChange={(e) => onUpdateParam('assigneeId', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All assignees</option>
{authUser && (
<option value={authUser.id}>Me ({authUser.displayName})</option>
)}
{users
.filter((u) => u.id !== authUser?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
<CTISelect
compact
value={{ categoryId, typeId, itemId }}
onChange={(cti) => {
onSetParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('page');
if (cti.categoryId) next.set('categoryId', cti.categoryId);
else next.delete('categoryId');
if (cti.typeId) next.set('typeId', cti.typeId);
else next.delete('typeId');
if (cti.itemId) next.set('itemId', cti.itemId);
else next.delete('itemId');
return next;
});
}}
/>
</div>
</>
);
}
+4 -3
View File
@@ -4,7 +4,6 @@ import { useShortcut } from '../../hooks/useShortcuts';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import { TicketStatus } from '../../types';
import { useAuth } from '../../contexts/AuthContext';
import {
useTicketsPaged,
@@ -44,7 +43,9 @@ export default function Tickets() {
const { user: authUser } = useAuth();
const { data: users = [] } = useUsers();
const status = (params.get('status') ?? '') as TicketStatus | '';
const DEFAULT_STATUSES = 'OPEN,IN_PROGRESS';
const statusParam = params.get('status');
const status = statusParam === null ? DEFAULT_STATUSES : statusParam;
const severity = params.get('severity') ?? '';
const assigneeId = params.get('assigneeId') ?? '';
const categoryId = params.get('categoryId') ?? '';
@@ -88,7 +89,7 @@ export default function Tickets() {
};
const queryParams = {
status: status || undefined,
status: status || undefined as string | undefined,
severity: severity ? Number(severity) : undefined,
assigneeId: assigneeId || undefined,
categoryId: categoryId || undefined,
+8 -1
View File
@@ -6,7 +6,14 @@ function whereConditions(query: string, filters: TicketFilters): Prisma.Sql[] {
const conds: Prisma.Sql[] = [
Prisma.sql`"searchVector" @@ plainto_tsquery('english', ${query})`,
];
if (filters.status) conds.push(Prisma.sql`"status" = ${filters.status}::"TicketStatus"`);
if (filters.status) {
const statuses = filters.status.split(',');
if (statuses.length === 1) {
conds.push(Prisma.sql`"status" = ${statuses[0]}::"TicketStatus"`);
} else {
conds.push(Prisma.sql`"status" IN (${Prisma.join(statuses.map(s => Prisma.sql`${s}::"TicketStatus"`))})`);
}
}
if (filters.severity !== undefined) conds.push(Prisma.sql`"severity" = ${filters.severity}`);
if (filters.assigneeId) conds.push(Prisma.sql`"assigneeId" = ${filters.assigneeId}`);
if (filters.createdById) conds.push(Prisma.sql`"createdById" = ${filters.createdById}`);
+6 -1
View File
@@ -80,7 +80,12 @@ export function findByIdOrDisplay(idOrDisplay: string) {
export function buildTicketWhere(filters: TicketFilters): Prisma.TicketWhereInput {
const where: Prisma.TicketWhereInput = {};
if (filters.status) where.status = filters.status as Prisma.TicketWhereInput['status'];
if (filters.status) {
const statuses = filters.status.split(',');
where.status = statuses.length === 1
? (statuses[0] as Prisma.TicketWhereInput['status'])
: { in: statuses as string[] } as Prisma.TicketWhereInput['status'];
}
if (filters.severity) where.severity = filters.severity;
if (filters.assigneeId) where.assigneeId = filters.assigneeId;
if (filters.createdById) where.createdById = filters.createdById;