Adjust ticket filters visibility and add date range

This commit is contained in:
Esdras Renan 2025-11-13 09:48:54 -03:00
parent 5f7ef3fd03
commit cc68c85246
3 changed files with 110 additions and 91 deletions

View file

@ -21,6 +21,7 @@ function getParamValue(value: string | string[] | undefined): string | undefined
function deriveInitialFilters(params: Record<string, string | string[] | undefined>): Partial<TicketFiltersState> { function deriveInitialFilters(params: Record<string, string | string[] | undefined>): Partial<TicketFiltersState> {
const initial: Partial<TicketFiltersState> = {} const initial: Partial<TicketFiltersState> = {}
const isValidDateParam = (value?: string) => Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value))
const view = getParamValue(params.view) const view = getParamValue(params.view)
if (view === "completed" || view === "active") { if (view === "completed" || view === "active") {
initial.view = view initial.view = view
@ -41,10 +42,10 @@ function deriveInitialFilters(params: Record<string, string | string[] | undefin
if (assigneeId) initial.assigneeId = assigneeId if (assigneeId) initial.assigneeId = assigneeId
const categoryId = getParamValue(params.category) const categoryId = getParamValue(params.category)
if (categoryId) initial.categoryId = categoryId if (categoryId) initial.categoryId = categoryId
const dateFrom = getParamValue(params.from) const from = getParamValue(params.from ?? params.dateFrom)
if (dateFrom) initial.dateFrom = dateFrom if (isValidDateParam(from)) initial.dateFrom = from
const dateTo = getParamValue(params.to) const to = getParamValue(params.to ?? params.dateTo)
if (dateTo) initial.dateTo = dateTo if (isValidDateParam(to)) initial.dateTo = to
const sort = getParamValue(params.sort) const sort = getParamValue(params.sort)
if (sort === "recent" || sort === "oldest") { if (sort === "recent" || sort === "oldest") {
initial.sort = sort as TicketFiltersState["sort"] initial.sort = sort as TicketFiltersState["sort"]

View file

@ -16,6 +16,7 @@ export { defaultTicketFilters }
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@ -28,6 +29,21 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { DatePicker } from "@/components/ui/date-picker"
type QueueOption = string
interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[]
companies?: string[]
assignees?: Array<{ id: string; name: string }>
categories?: Array<{ id: string; name: string }>
initialState?: Partial<TicketFiltersState>
viewerRole?: string | null
}
const ALL_VALUE = "ALL"
const statusOptions: Array<{ value: TicketStatus; label: string }> = [ const statusOptions: Array<{ value: TicketStatus; label: string }> = [
{ value: "PENDING", label: "Pendente" }, { value: "PENDING", label: "Pendente" },
@ -63,19 +79,6 @@ const channelOptions = ticketChannelSchema.options.map((channel) => ({
}[channel], }[channel],
})) }))
type QueueOption = string
interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[]
companies?: string[]
assignees?: Array<{ id: string; name: string }>
categories?: Array<{ id: string; name: string }>
initialState?: Partial<TicketFiltersState>
}
const ALL_VALUE = "ALL"
export function TicketsFilters({ export function TicketsFilters({
onChange, onChange,
queues = [], queues = [],
@ -83,6 +86,7 @@ export function TicketsFilters({
assignees = [], assignees = [],
categories = [], categories = [],
initialState, initialState,
viewerRole,
}: TicketsFiltersProps) { }: TicketsFiltersProps) {
const mergedDefaults = useMemo( const mergedDefaults = useMemo(
() => ({ () => ({
@ -101,19 +105,21 @@ export function TicketsFilters({
setFilters((prev) => ({ ...prev, ...partial })) setFilters((prev) => ({ ...prev, ...partial }))
} }
// Propaga as mudancas de filtros para o componente pai sem disparar durante a renderizacao
useEffect(() => { useEffect(() => {
onChange?.(filters) onChange?.(filters)
}, [filters, onChange]) }, [filters, onChange])
const normalizedRole = viewerRole?.toLowerCase() ?? null
const canUseAdvancedFilters = normalizedRole === "admin" || normalizedRole === "agent"
const activeFilters = useMemo(() => { const activeFilters = useMemo(() => {
const chips: string[] = [] const chips: string[] = []
if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`) if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`)
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`) if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
if (filters.queue) chips.push(`Fila: ${filters.queue}`) if (filters.queue && canUseAdvancedFilters) chips.push(`Fila: ${filters.queue}`)
if (filters.channel) chips.push(`Canal: ${filters.channel}`) if (filters.channel) chips.push(`Canal: ${filters.channel}`)
if (filters.company) chips.push(`Empresa: ${filters.company}`) if (filters.company && canUseAdvancedFilters) chips.push(`Empresa: ${filters.company}`)
if (filters.assigneeId) { if (filters.assigneeId && canUseAdvancedFilters) {
const found = assignees.find((a) => a.id === filters.assigneeId) const found = assignees.find((a) => a.id === filters.assigneeId)
chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`) chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`)
} }
@ -126,50 +132,54 @@ export function TicketsFilters({
if (filters.dateFrom || filters.dateTo) chips.push(formatDateRangeChip(filters.dateFrom, filters.dateTo)) if (filters.dateFrom || filters.dateTo) chips.push(formatDateRangeChip(filters.dateFrom, filters.dateTo))
if (filters.sort === "oldest") chips.push("Ordenados por mais antigos") if (filters.sort === "oldest") chips.push("Ordenados por mais antigos")
return chips return chips
}, [filters, assignees, categories]) }, [filters, assignees, categories, canUseAdvancedFilters])
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row"> <div className="flex flex-1 flex-col gap-2 md:flex-row md:flex-wrap">
<Input <Input
placeholder="Buscar por assunto ou #ID" placeholder="Buscar por assunto ou #ID"
value={filters.search} value={filters.search}
onChange={(event) => setPartial({ search: event.target.value })} onChange={(event) => setPartial({ search: event.target.value })}
className="md:max-w-sm" className="md:max-w-sm"
/> />
<Select {canUseAdvancedFilters ? (
value={filters.queue ?? ALL_VALUE} <Select
onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })} value={filters.queue ?? ALL_VALUE}
> onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })}
<SelectTrigger className="md:w-[180px]"> >
<SelectValue placeholder="Fila" /> <SelectTrigger className="md:w-[180px]">
</SelectTrigger> <SelectValue placeholder="Fila" />
<SelectContent> </SelectTrigger>
<SelectItem value={ALL_VALUE}>Todas as filas</SelectItem> <SelectContent>
{queues.map((queue) => ( <SelectItem value={ALL_VALUE}>Todas as filas</SelectItem>
<SelectItem key={queue!} value={queue!}> {queues.map((queue) => (
{queue} <SelectItem key={queue!} value={queue!}>
</SelectItem> {queue}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
<Select </Select>
value={filters.company ?? ALL_VALUE} ) : null}
onValueChange={(value) => setPartial({ company: value === ALL_VALUE ? null : value })} {canUseAdvancedFilters ? (
> <Select
<SelectTrigger className="md:w-[220px]"> value={filters.company ?? ALL_VALUE}
<SelectValue placeholder="Empresa" /> onValueChange={(value) => setPartial({ company: value === ALL_VALUE ? null : value })}
</SelectTrigger> >
<SelectContent> <SelectTrigger className="md:w-[220px]">
<SelectItem value={ALL_VALUE}>Todas as empresas</SelectItem> <SelectValue placeholder="Empresa" />
{companies.map((company) => ( </SelectTrigger>
<SelectItem key={company!} value={company!}> <SelectContent>
{company} <SelectItem value={ALL_VALUE}>Todas as empresas</SelectItem>
</SelectItem> {companies.map((company) => (
))} <SelectItem key={company!} value={company!}>
</SelectContent> {company}
</Select> </SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Select <Select
value={filters.categoryId ?? ALL_VALUE} value={filters.categoryId ?? ALL_VALUE}
onValueChange={(value) => setPartial({ categoryId: value === ALL_VALUE ? null : value })} onValueChange={(value) => setPartial({ categoryId: value === ALL_VALUE ? null : value })}
@ -187,23 +197,25 @@ export function TicketsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Select {canUseAdvancedFilters ? (
value={filters.assigneeId ?? ALL_VALUE} <Select
onValueChange={(value) => setPartial({ assigneeId: value === ALL_VALUE ? null : value })} value={filters.assigneeId ?? ALL_VALUE}
> onValueChange={(value) => setPartial({ assigneeId: value === ALL_VALUE ? null : value })}
<SelectTrigger className="md:w-[220px]"> >
<SelectValue placeholder="Responsável" /> <SelectTrigger className="md:w-[220px]">
</SelectTrigger> <SelectValue placeholder="Responsável" />
<SelectContent> </SelectTrigger>
<SelectItem value={ALL_VALUE}>Todos os responsáveis</SelectItem> <SelectContent>
{assignees.map((user) => ( <SelectItem value={ALL_VALUE}>Todos os responsáveis</SelectItem>
<SelectItem key={user.id} value={user.id}> {assignees.map((user) => (
{user.name} <SelectItem key={user.id} value={user.id}>
</SelectItem> {user.name}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Select>
) : null}
<Select <Select
value={filters.view} value={filters.view}
onValueChange={(value) => setPartial({ view: value as TicketFiltersState["view"] })} onValueChange={(value) => setPartial({ view: value as TicketFiltersState["view"] })}
@ -305,23 +317,6 @@ export function TicketsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-neutral-500">
Período
</p>
<div className="flex gap-2">
<Input
type="date"
value={filters.dateFrom ?? ""}
onChange={(event) => setPartial({ dateFrom: event.target.value || null })}
/>
<Input
type="date"
value={filters.dateTo ?? ""}
onChange={(event) => setPartial({ dateTo: event.target.value || null })}
/>
</div>
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button <Button
@ -335,6 +330,29 @@ export function TicketsFilters({
</Button> </Button>
</div> </div>
</div> </div>
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Período</p>
</div>
<div className="mt-3 flex flex-wrap gap-3">
<div className="flex min-w-[200px] flex-1 flex-col gap-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">A partir de</Label>
<DatePicker
value={filters.dateFrom}
onChange={(value) => setPartial({ dateFrom: value })}
placeholder="Selecionar data"
/>
</div>
<div className="flex min-w-[200px] flex-1 flex-col gap-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Até</Label>
<DatePicker
value={filters.dateTo}
onChange={(value) => setPartial({ dateTo: value })}
placeholder="Selecionar data"
/>
</div>
</div>
</div>
{activeFilters.length > 0 && ( {activeFilters.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{activeFilters.map((chip) => ( {activeFilters.map((chip) => (

View file

@ -37,7 +37,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
setFilters(mergedInitialFilters) setFilters(mergedInitialFilters)
}, [mergedInitialFilters]) }, [mergedInitialFilters])
const { session, convexUserId, isStaff } = useAuth() const { session, convexUserId, isStaff, role } = useAuth()
const userId = session?.user?.id ?? null const userId = session?.user?.id ?? null
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
const viewModeStorageKey = useMemo(() => { const viewModeStorageKey = useMemo(() => {
@ -111,7 +111,6 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
} }
}, []) }, [])
// load saved filters as defaults per user
useEffect(() => { useEffect(() => {
if (!convexUserId) return if (!convexUserId) return
try { try {
@ -230,6 +229,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))} assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
categories={ticketCategories.map((category) => ({ id: category.id, name: category.name }))} categories={ticketCategories.map((category) => ({ id: category.id, name: category.name }))}
initialState={mergedInitialFilters} initialState={mergedInitialFilters}
viewerRole={role}
/> />
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<ToggleGroup <ToggleGroup