Align report filters and update work session flows

This commit is contained in:
Esdras Renan 2025-11-19 09:24:30 -03:00
parent 17c1de2272
commit ff9d95746e
7 changed files with 106 additions and 83 deletions

View file

@ -20,6 +20,7 @@ type DateRangeButtonProps = {
onChange: (next: DateRangeValue) => void
className?: string
clearLabel?: string
align?: "left" | "center"
}
function strToDate(value?: string | null): Date | undefined {
@ -41,7 +42,14 @@ function formatPtBR(value?: Date): string {
return value ? value.toLocaleDateString("pt-BR") : ""
}
export function DateRangeButton({ from, to, onChange, className, clearLabel = "Limpar período" }: DateRangeButtonProps) {
export function DateRangeButton({
from,
to,
onChange,
className,
clearLabel = "Limpar período",
align = "left",
}: DateRangeButtonProps) {
const [open, setOpen] = useState(false)
const range: DateRange | undefined = useMemo(
() => ({
@ -123,7 +131,7 @@ export function DateRangeButton({ from, to, onChange, className, clearLabel = "L
<PopoverTrigger asChild>
<Button
variant="outline"
className={`flex h-10 w-full items-center justify-start gap-2 rounded-2xl border-slate-300 bg-white/95 text-sm font-semibold text-neutral-700 ${className ?? ""}`}
className={`flex h-10 w-full items-center gap-2 rounded-2xl border-slate-300 bg-white/95 text-sm font-semibold text-neutral-700 ${align === "center" ? "justify-center text-center" : "justify-start text-left"} ${className ?? ""}`}
>
<IconCalendar className="size-4 text-neutral-500" />
<span className="truncate">{label}</span>

View file

@ -237,7 +237,8 @@ export function HoursReport() {
onValueChange={(value) => setCompanyId(value ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
triggerClassName="h-10 w-full rounded-2xl border border-border/60 bg-white px-3 text-left text-sm font-semibold text-neutral-800 lg:w-64"
triggerClassName="h-10 w-full rounded-2xl border border-border/60 bg-white px-3 text-sm font-semibold text-neutral-800 lg:w-64"
align="center"
/>
<DateRangeButton
from={dateFrom}
@ -246,23 +247,33 @@ export function HoursReport() {
setDateFrom(from)
setDateTo(to)
}}
className="w-full min-w-[200px] lg:w-auto"
className="w-full min-w-[200px] lg:w-auto lg:flex-1"
align="center"
/>
</div>
<div className="flex flex-1 justify-center">
<div className="flex w-full justify-start lg:w-auto lg:justify-end lg:ml-auto">
<ToggleGroup
type="single"
value={billingFilter}
onValueChange={(value) => value && setBillingFilter(value as typeof billingFilter)}
className="inline-flex rounded-full border border-border/60 bg-white/80 p-1 overflow-hidden"
className="inline-flex rounded-full border border-border/60 bg-white/80 p-1 shadow-sm"
>
<ToggleGroupItem value="all" className="rounded-full px-6 py-2 text-xs font-semibold whitespace-nowrap">
<ToggleGroupItem
value="all"
className="rounded-full px-6 py-2 text-xs font-semibold whitespace-nowrap transition first:rounded-l-full last:rounded-r-full"
>
Todos
</ToggleGroupItem>
<ToggleGroupItem value="avulso" className="rounded-full px-6 py-2 text-xs font-semibold whitespace-nowrap">
<ToggleGroupItem
value="avulso"
className="rounded-full px-6 py-2 text-xs font-semibold whitespace-nowrap transition first:rounded-l-full last:rounded-r-full"
>
Somente avulsos
</ToggleGroupItem>
<ToggleGroupItem value="contratado" className="rounded-full px-6 py-2 text-xs font-semibold whitespace-nowrap">
<ToggleGroupItem
value="contratado"
className="rounded-full px-6 py-2 text-xs font-semibold whitespace-nowrap transition first:rounded-l-full last:rounded-r-full"
>
Somente contratados
</ToggleGroupItem>
</ToggleGroup>

View file

@ -483,7 +483,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const hasAssignee = Boolean(currentAssigneeId)
const isCurrentResponsible = hasAssignee && convexUserId ? currentAssigneeId === convexUserId : false
const isResolved = status === "RESOLVED"
const canControlWork = !isResolved && (isAdmin || !hasAssignee || isCurrentResponsible)
const canControlWork = !isResolved && isStaff && hasAssignee
const canPauseWork = !isResolved && (isAdmin || isCurrentResponsible)
const pauseDisabled = !canPauseWork
const startDisabled = !canControlWork
@ -491,11 +491,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
if (isResolved) {
return "Este chamado está encerrado. Reabra o ticket para iniciar um novo atendimento."
}
if (!isAdmin && hasAssignee && !isCurrentResponsible) {
return "Apenas o responsável atual ou um administrador pode iniciar este atendimento."
if (!hasAssignee) {
return "Defina um responsável antes de iniciar o atendimento."
}
if (!isStaff) {
return "Apenas a equipe interna pode iniciar este atendimento."
}
return "Não é possível iniciar o atendimento neste momento."
}, [isResolved, isAdmin, hasAssignee, isCurrentResponsible])
}, [isResolved, hasAssignee, isStaff])
useEffect(() => {
if (!customersInitialized) {
@ -664,11 +667,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setAssigneeSelection(currentAssigneeId)
throw new Error("invalid-assignee")
} else {
if (status === "AWAITING_ATTENDANCE" || workSummary?.activeSession) {
toast.error("Pause o atendimento antes de reatribuir o chamado.", { id: "assignee" })
setAssigneeSelection(currentAssigneeId)
throw new Error("assignee-not-allowed")
}
const reasonValue = assigneeChangeReason.trim()
if (reasonValue.length > 0 && reasonValue.length < 5) {
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.")
@ -985,6 +983,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const handleStartWork = async (workType: "INTERNAL" | "EXTERNAL") => {
if (!convexUserId) return
if (!assigneeState?.id) {
toast.error("Defina um responsável antes de iniciar o atendimento.")
return
}
toast.dismiss("work")
toast.loading("Iniciando atendimento...", { id: "work" })
try {
@ -1029,18 +1031,21 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
activeSession: null,
perAgentTotals: [],
}
const actorId = String(convexUserId)
const sessionAgentId = assigneeState?.id ? String(assigneeState.id) : ""
if (!sessionAgentId) {
return base
}
const existingTotals = base.perAgentTotals ?? []
const hasActorEntry = existingTotals.some((item) => item.agentId === actorId)
const updatedTotals = hasActorEntry
const hasAgentEntry = existingTotals.some((item) => item.agentId === sessionAgentId)
const updatedTotals = hasAgentEntry
? existingTotals
: [
...existingTotals,
{
agentId: actorId,
agentName: viewerAgentMeta?.name ?? null,
agentEmail: viewerAgentMeta?.email ?? null,
avatarUrl: viewerAgentMeta?.avatarUrl ?? null,
agentId: sessionAgentId,
agentName: assigneeState?.name ?? viewerAgentMeta?.name ?? null,
agentEmail: assigneeState?.email ?? viewerAgentMeta?.email ?? null,
avatarUrl: assigneeState?.avatarUrl ?? viewerAgentMeta?.avatarUrl ?? null,
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
@ -1051,7 +1056,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
serverNow: typeof resultMeta?.serverNow === "number" ? resultMeta.serverNow : getServerNow(),
activeSession: {
id: (sessionId as Id<"ticketWorkSessions">) ?? (base.activeSession?.id as Id<"ticketWorkSessions">),
agentId: actorId,
agentId: sessionAgentId,
startedAt: startedAtMs,
workType,
},
@ -1060,21 +1065,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
})
setStatus("AWAITING_ATTENDANCE")
if (viewerAgentMeta) {
setAssigneeState((prevAssignee) => {
if (prevAssignee && prevAssignee.id === viewerAgentMeta.id) {
return prevAssignee
}
return {
id: viewerAgentMeta.id,
name: viewerAgentMeta.name ?? prevAssignee?.name ?? "Responsável",
email: viewerAgentMeta.email ?? prevAssignee?.email ?? "",
avatarUrl: viewerAgentMeta.avatarUrl ?? prevAssignee?.avatarUrl ?? undefined,
teams: prevAssignee?.teams ?? [],
}
})
setAssigneeSelection(viewerAgentMeta.id)
}
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível atualizar o atendimento"
toast.error(message, { id: "work" })

View file

@ -148,6 +148,7 @@ export function TicketsFilters({
to={filters.dateTo}
onChange={({ from, to }) => setPartial({ dateFrom: from, dateTo: to })}
className="w-full min-w-[200px] rounded-2xl border-slate-300 bg-white/95 text-left text-sm font-semibold text-neutral-700 lg:w-auto"
align="center"
/>
<Popover>
<PopoverTrigger asChild>
@ -253,6 +254,7 @@ export function TicketsFilters({
clearLabel="Todas as empresas"
triggerClassName={fieldTrigger}
prefix={<IconBuilding className="size-4 text-neutral-400" />}
align="center"
/>
</div>
)}

View file

@ -34,6 +34,7 @@ type SearchableComboboxProps = {
scrollClassName?: string
scrollProps?: React.HTMLAttributes<HTMLDivElement>
prefix?: ReactNode
align?: "left" | "center"
}
export function SearchableCombobox({
@ -54,6 +55,7 @@ export function SearchableCombobox({
scrollClassName,
scrollProps,
prefix,
align = "left",
}: SearchableComboboxProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
@ -109,7 +111,7 @@ export function SearchableCombobox({
>
<span className="flex flex-1 items-center gap-2">
{prefix ? <span className="inline-flex items-center text-neutral-400">{prefix}</span> : null}
<span className="flex-1 truncate text-left">
<span className={cn("flex-1 truncate", align === "center" ? "text-center" : "text-left")}>
{renderValue ? (
renderValue(selected)
) : selected?.label ? (