Compare commits
3 Commits
2177162300
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d9464a6fb | |||
| c6ec47a8fc | |||
| cfe7ad56ff |
@@ -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)}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user