Replace status tabs with multi-select checkbox dropdown, default to Open + In Progress
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>
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import { Trash2, Save } from 'lucide-react';
|
import { Trash2, Save, Check } from 'lucide-react';
|
||||||
import CTISelect from '../../components/CTISelect';
|
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 type { SavedView } from '../../../../shared/types';
|
||||||
|
import { STATUS_LABELS } from '../../../../shared/constants/labels';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -13,16 +15,8 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface TicketFiltersProps {
|
interface TicketFiltersProps {
|
||||||
status: TicketStatus | '';
|
status: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
assigneeId: string;
|
assigneeId: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
@@ -64,25 +58,22 @@ export default function TicketFilters({
|
|||||||
total,
|
total,
|
||||||
isFetching,
|
isFetching,
|
||||||
}: TicketFiltersProps) {
|
}: 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 (
|
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>
|
|
||||||
|
|
||||||
{/* Row 1: Search + saved views + result count */}
|
{/* Row 1: Search + saved views + result count */}
|
||||||
<div className="flex gap-2 mb-2 items-center">
|
<div className="flex gap-2 mb-2 items-center">
|
||||||
<input
|
<input
|
||||||
@@ -151,6 +142,31 @@ export default function TicketFilters({
|
|||||||
|
|
||||||
{/* Row 2: Filter selectors */}
|
{/* Row 2: Filter selectors */}
|
||||||
<div className="flex gap-2 mb-4 items-center flex-wrap">
|
<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>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={severity}
|
value={severity}
|
||||||
onChange={(e) => onUpdateParam('severity', e.target.value || null)}
|
onChange={(e) => onUpdateParam('severity', e.target.value || null)}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useShortcut } from '../../hooks/useShortcuts';
|
|||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import { TicketStatus } from '../../types';
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import {
|
import {
|
||||||
useTicketsPaged,
|
useTicketsPaged,
|
||||||
@@ -44,7 +43,8 @@ export default function Tickets() {
|
|||||||
const { user: authUser } = useAuth();
|
const { user: authUser } = useAuth();
|
||||||
const { data: users = [] } = useUsers();
|
const { data: users = [] } = useUsers();
|
||||||
|
|
||||||
const status = (params.get('status') ?? '') as TicketStatus | '';
|
const DEFAULT_STATUSES = 'OPEN,IN_PROGRESS';
|
||||||
|
const status = params.get('status') ?? DEFAULT_STATUSES;
|
||||||
const severity = params.get('severity') ?? '';
|
const severity = params.get('severity') ?? '';
|
||||||
const assigneeId = params.get('assigneeId') ?? '';
|
const assigneeId = params.get('assigneeId') ?? '';
|
||||||
const categoryId = params.get('categoryId') ?? '';
|
const categoryId = params.get('categoryId') ?? '';
|
||||||
@@ -88,7 +88,7 @@ export default function Tickets() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
status: status || undefined,
|
status: status || undefined as string | undefined,
|
||||||
severity: severity ? Number(severity) : undefined,
|
severity: severity ? Number(severity) : undefined,
|
||||||
assigneeId: assigneeId || undefined,
|
assigneeId: assigneeId || undefined,
|
||||||
categoryId: categoryId || undefined,
|
categoryId: categoryId || undefined,
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ function whereConditions(query: string, filters: TicketFilters): Prisma.Sql[] {
|
|||||||
const conds: Prisma.Sql[] = [
|
const conds: Prisma.Sql[] = [
|
||||||
Prisma.sql`"searchVector" @@ plainto_tsquery('english', ${query})`,
|
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.severity !== undefined) conds.push(Prisma.sql`"severity" = ${filters.severity}`);
|
||||||
if (filters.assigneeId) conds.push(Prisma.sql`"assigneeId" = ${filters.assigneeId}`);
|
if (filters.assigneeId) conds.push(Prisma.sql`"assigneeId" = ${filters.assigneeId}`);
|
||||||
if (filters.createdById) conds.push(Prisma.sql`"createdById" = ${filters.createdById}`);
|
if (filters.createdById) conds.push(Prisma.sql`"createdById" = ${filters.createdById}`);
|
||||||
|
|||||||
@@ -80,7 +80,12 @@ export function findByIdOrDisplay(idOrDisplay: string) {
|
|||||||
|
|
||||||
export function buildTicketWhere(filters: TicketFilters): Prisma.TicketWhereInput {
|
export function buildTicketWhere(filters: TicketFilters): Prisma.TicketWhereInput {
|
||||||
const where: 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.severity) where.severity = filters.severity;
|
||||||
if (filters.assigneeId) where.assigneeId = filters.assigneeId;
|
if (filters.assigneeId) where.assigneeId = filters.assigneeId;
|
||||||
if (filters.createdById) where.createdById = filters.createdById;
|
if (filters.createdById) where.createdById = filters.createdById;
|
||||||
|
|||||||
Reference in New Issue
Block a user