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 };
|
value: { categoryId: string; typeId: string; itemId: string };
|
||||||
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void;
|
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void;
|
||||||
disabled?: boolean;
|
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: categories = [] } = useCategories();
|
||||||
const { data: types = [] } = useTypes(value.categoryId || undefined);
|
const { data: types = [] } = useTypes(value.categoryId || undefined);
|
||||||
const { data: items = [] } = useItems(value.typeId || undefined);
|
const { data: items = [] } = useItems(value.typeId || undefined);
|
||||||
@@ -24,12 +25,58 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectClass =
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<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
|
<select
|
||||||
value={value.categoryId}
|
value={value.categoryId}
|
||||||
onChange={(e) => handleCategory(e.target.value)}
|
onChange={(e) => handleCategory(e.target.value)}
|
||||||
@@ -46,7 +93,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<select
|
||||||
value={value.typeId}
|
value={value.typeId}
|
||||||
onChange={(e) => handleType(e.target.value)}
|
onChange={(e) => handleType(e.target.value)}
|
||||||
@@ -63,7 +110,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<select
|
||||||
value={value.itemId}
|
value={value.itemId}
|
||||||
onChange={(e) => handleItem(e.target.value)}
|
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 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,
|
||||||
@@ -10,17 +12,11 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [
|
import { Badge } from '@/components/ui/badge';
|
||||||
{ 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;
|
||||||
@@ -62,91 +58,43 @@ 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 */}
|
{/* Row 1: Search + saved views + result count */}
|
||||||
<div className="flex items-center border-b border-border mb-4 -mx-4 px-4 overflow-x-auto">
|
<div className="flex gap-2 mb-2 items-center">
|
||||||
{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">
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder="Search title, overview, ID…"
|
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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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
|
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>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-64">
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
<DropdownMenuLabel>Saved views</DropdownMenuLabel>
|
<DropdownMenuLabel>Saved views</DropdownMenuLabel>
|
||||||
@@ -191,6 +139,102 @@ export default function TicketFilters({
|
|||||||
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
|
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
|
||||||
</div>
|
</div>
|
||||||
</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 { 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,9 @@ 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 statusParam = params.get('status');
|
||||||
|
const status = statusParam === null ? DEFAULT_STATUSES : statusParam;
|
||||||
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 +89,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