feat: modernize report scheduling UI and date inputs

This commit is contained in:
Esdras Renan 2025-11-10 11:05:53 -03:00
parent 8cc513c532
commit 616fe42e10
10 changed files with 384 additions and 60 deletions

View file

@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import dynamic from "next/dynamic"
import { Controller, FormProvider, useFieldArray, useForm, type UseFormReturn } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import {
@ -86,7 +87,8 @@ import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { MultiValueInput } from "@/components/ui/multi-value-input"
import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview"
import { Spinner } from "@/components/ui/spinner"
import { DatePicker } from "@/components/ui/date-picker"
type LastAlertInfo = { createdAt: number; usagePct: number; threshold: number } | null
@ -101,6 +103,22 @@ type EditorState =
| { mode: "create" }
| { mode: "edit"; company: NormalizedCompany }
const AdminDevicesOverview = dynamic(
() =>
import("@/components/admin/devices/admin-devices-overview").then(
(mod) => mod.AdminDevicesOverview
),
{
ssr: false,
loading: () => (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" />
<span>Carregando dispositivos...</span>
</div>
),
}
)
const BOARD_COLUMNS = [
{ id: "monthly", title: "Mensalistas", description: "Contratos recorrentes ou planos mensais." },
{ id: "time_bank", title: "Banco de horas", description: "Clientes com consumo controlado por horas." },
@ -1790,17 +1808,48 @@ function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: Compa
</div>
<div className="space-y-2">
<Label>Data de início</Label>
<Input type="date" {...form.register(`contracts.${index}.startDate`)} />
<Controller
name={`contracts.${index}.startDate`}
control={form.control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={(next) => field.onChange(next ?? null)}
placeholder="Selecionar data"
/>
)}
/>
<FieldError error={fieldErrors?.startDate?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label>Data de fim</Label>
<Input type="date" {...form.register(`contracts.${index}.endDate`)} />
<Controller
name={`contracts.${index}.endDate`}
control={form.control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={(next) => field.onChange(next ?? null)}
placeholder="Selecionar data"
/>
)}
/>
<FieldError error={fieldErrors?.endDate?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label>Renovação</Label>
<Input type="date" {...form.register(`contracts.${index}.renewalDate`)} />
<Controller
name={`contracts.${index}.renewalDate`}
control={form.control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={(next) => field.onChange(next ?? null)}
placeholder="Selecionar data"
allowClear
/>
)}
/>
</div>
<div className="space-y-2">
<Label>Valor mensal/projeto</Label>

View file

@ -72,6 +72,8 @@ import type { Id } from "@/convex/_generated/dataModel"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager"
import { DatePicker } from "@/components/ui/date-picker"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type DeviceMetrics = Record<string, unknown> | null
@ -1342,6 +1344,13 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
}, [companies, devices])
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
return companyOptions.map((company) => ({
value: company.slug,
label: company.name,
}))
}, [companyOptions])
const deviceFields = useQuery(
api.deviceFields.listForTenant,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
@ -2250,23 +2259,17 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
</div>
<div className="grid gap-2">
<label className="text-sm font-medium text-slate-700">Empresa</label>
<Select
value={newDeviceCompanySlug ?? "all"}
onValueChange={(value) => setNewDeviceCompanySlug(value === "all" ? null : value)}
<SearchableCombobox
value={newDeviceCompanySlug}
onValueChange={setNewDeviceCompanySlug}
options={companyComboboxOptions}
placeholder="Sem empresa"
searchPlaceholder="Buscar empresa..."
emptyText="Nenhuma empresa encontrada."
allowClear
clearLabel="Sem empresa"
disabled={createDeviceLoading}
>
<SelectTrigger>
<SelectValue placeholder="Sem empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Sem empresa</SelectItem>
{companyOptions.map((option) => (
<SelectItem key={option.slug} value={option.slug}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
<div className="grid gap-2">
<label htmlFor="device-serials" className="text-sm font-medium text-slate-700">
@ -5595,7 +5598,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : field.type === "number" ? (
<Input type="number" value={value == null ? "" : String(value)} onChange={(e) => setValue(e.target.value === "" ? null : Number(e.target.value))} />
) : field.type === "date" ? (
<Input type="date" value={value ? String(value).slice(0, 10) : ""} onChange={(e) => setValue(e.target.value || null)} />
<DatePicker
value={value ? String(value).slice(0, 10) : null}
onChange={(next) => setValue(next ?? null)}
placeholder="Selecionar data"
/>
) : field.type === "boolean" ? (
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
<Checkbox checked={Boolean(value)} onCheckedChange={(v) => setValue(Boolean(v))} />

View file

@ -19,6 +19,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { DatePicker } from "@/components/ui/date-picker"
import { cn } from "@/lib/utils"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Spinner } from "@/components/ui/spinner"
@ -287,17 +288,15 @@ export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: {
</Select>
{periodPreset === "custom" ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
type="date"
<DatePicker
value={customFrom}
onChange={(event) => setCustomFrom(event.target.value)}
onChange={(value) => setCustomFrom(value ?? "")}
className="sm:w-[160px]"
placeholder="Início"
/>
<Input
type="date"
<DatePicker
value={customTo}
onChange={(event) => setCustomTo(event.target.value)}
onChange={(value) => setCustomTo(value ?? "")}
className="sm:w-[160px]"
placeholder="Fim"
/>

View file

@ -50,6 +50,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { MultiValueInput } from "@/components/ui/multi-value-input"
import { DatePicker } from "@/components/ui/date-picker"
import {
Select,
SelectContent,
@ -2014,21 +2015,52 @@ function ContractsEditor({
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Início
</Label>
<Input type="date" {...form.register(`contracts.${index}.startDate` as const)} />
<Controller
name={`contracts.${index}.startDate` as const}
control={form.control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={(next) => field.onChange(next ?? null)}
placeholder="Selecione a data"
/>
)}
/>
<FieldError message={errors?.startDate?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Fim
</Label>
<Input type="date" {...form.register(`contracts.${index}.endDate` as const)} />
<Controller
name={`contracts.${index}.endDate` as const}
control={form.control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={(next) => field.onChange(next ?? null)}
placeholder="Selecione a data"
/>
)}
/>
<FieldError message={errors?.endDate?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Renovação
</Label>
<Input type="date" {...form.register(`contracts.${index}.renewalDate` as const)} />
<Controller
name={`contracts.${index}.renewalDate` as const}
control={form.control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={(next) => field.onChange(next ?? null)}
placeholder="Selecione a data"
allowClear
/>
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">

View file

@ -50,6 +50,9 @@ const queueSparkConfig = {
resolved: { label: "Resolvidos", color: "var(--chart-2)" },
}
const metricBadgeClass =
"gap-1 rounded-full border-border/60 px-2.5 py-0.5 text-[11px] sm:px-3 sm:py-1 sm:text-xs"
export function DashboardHero() {
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -135,7 +138,8 @@ export function DashboardHero() {
<Badge
variant="outline"
className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold",
metricBadgeClass,
"font-semibold",
newTicketsTrend.delta === null
? "text-neutral-500"
: newTicketsTrend.delta < 0
@ -170,7 +174,8 @@ export function DashboardHero() {
<Badge
variant="outline"
className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold",
metricBadgeClass,
"font-semibold",
inProgressTrend.delta === null
? "text-neutral-500"
: inProgressTrend.delta > 0
@ -213,7 +218,8 @@ export function DashboardHero() {
<Badge
variant="outline"
className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold",
metricBadgeClass,
"font-semibold",
responseDelta.delta === null
? "text-neutral-500"
: responseDelta.delta > 0
@ -247,7 +253,8 @@ export function DashboardHero() {
<Badge
variant="outline"
className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold",
metricBadgeClass,
"font-semibold",
resolutionInfo?.delta === null
? "text-neutral-500"
: resolutionInfo?.delta !== null && resolutionInfo.delta < 0
@ -370,7 +377,8 @@ function QueueSparklineCard({
<Badge
variant="outline"
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
metricBadgeClass,
"font-medium",
net > 0 ? "text-amber-600" : net < 0 ? "text-emerald-600" : "text-neutral-500"
)}
>

View file

@ -5,6 +5,7 @@ import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"
type BillingFilter = "all" | "avulso" | "contratado"
type TimeRange = "90d" | "30d" | "7d"
@ -26,6 +27,12 @@ type ReportsFilterToolbarProps = {
onOpenScheduler?: () => void
}
const BILLING_TOGGLE_ITEM =
"rounded-full px-4 py-1 text-xs font-semibold transition data-[state=on]:bg-neutral-900 data-[state=on]:text-white data-[state=off]:text-neutral-600 hover:bg-neutral-100"
const RANGE_TOGGLE_ITEM =
"rounded-full px-4 py-1 text-sm font-semibold transition data-[state=on]:bg-neutral-900 data-[state=on]:text-white data-[state=off]:text-neutral-600 hover:bg-neutral-100"
export function ReportsFilterToolbar({
companyId,
onCompanyChange,
@ -57,15 +64,21 @@ export function ReportsFilterToolbar({
type="single"
value={billingFilter}
onValueChange={(next) => next && onBillingFilterChange?.(next as BillingFilter)}
className="inline-flex rounded-full border border-border/60 bg-muted/40 p-1"
className="inline-flex flex-1 flex-wrap items-center gap-2 rounded-2xl border border-border/60 bg-muted/40 p-1 sm:flex-none sm:flex-nowrap"
>
<ToggleGroupItem value="all" className="rounded-full px-3 text-xs">
<ToggleGroupItem value="all" className={cn(BILLING_TOGGLE_ITEM, "flex-1 justify-center sm:flex-none")}>
Todos
</ToggleGroupItem>
<ToggleGroupItem value="avulso" className="rounded-full px-3 text-xs">
<ToggleGroupItem
value="avulso"
className={cn(BILLING_TOGGLE_ITEM, "flex-1 justify-center sm:flex-none")}
>
Somente avulsos
</ToggleGroupItem>
<ToggleGroupItem value="contratado" className="rounded-full px-3 text-xs">
<ToggleGroupItem
value="contratado"
className={cn(BILLING_TOGGLE_ITEM, "flex-1 justify-center sm:flex-none")}
>
Somente contratados
</ToggleGroupItem>
</ToggleGroup>
@ -74,17 +87,15 @@ export function ReportsFilterToolbar({
type="single"
value={timeRange}
onValueChange={(value) => value && onTimeRangeChange(value as TimeRange)}
variant="outline"
size="lg"
className="flex rounded-2xl border border-border/60"
className="flex w-full flex-wrap items-center gap-2 rounded-2xl border border-border/60 bg-muted/30 p-1 sm:w-auto sm:flex-nowrap"
>
<ToggleGroupItem value="90d" className="min-w-[80px] justify-center px-4">
<ToggleGroupItem value="90d" className={cn(RANGE_TOGGLE_ITEM, "flex-1 min-w-[90px] justify-center sm:flex-none")}>
90 dias
</ToggleGroupItem>
<ToggleGroupItem value="30d" className="min-w-[80px] justify-center px-4">
<ToggleGroupItem value="30d" className={cn(RANGE_TOGGLE_ITEM, "flex-1 min-w-[90px] justify-center sm:flex-none")}>
30 dias
</ToggleGroupItem>
<ToggleGroupItem value="7d" className="min-w-[80px] justify-center px-4">
<ToggleGroupItem value="7d" className={cn(RANGE_TOGGLE_ITEM, "flex-1 min-w-[90px] justify-center sm:flex-none")}>
7 dias
</ToggleGroupItem>
</ToggleGroup>

View file

@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
@ -28,6 +28,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { TimePicker } from "@/components/ui/time-picker"
type SerializableRun = {
id: string
@ -118,6 +119,19 @@ export function ReportScheduleDrawer({
const [isLoading, setIsLoading] = useState(false)
const [schedules, setSchedules] = useState<SerializableSchedule[]>([])
const normalizedCompanyOptions = useMemo<SearchableComboboxOption[]>(() => {
const deduped = new Map<string, SearchableComboboxOption>()
companyOptions.forEach((option) => {
if (!deduped.has(option.value)) {
deduped.set(option.value, option)
}
})
const hasAll = deduped.has("all")
const baseLabel = hasAll ? deduped.get("all")?.label ?? "Todas as empresas" : "Todas as empresas"
const filtered = Array.from(deduped.values()).filter((option) => option.value !== "all")
return [{ value: "all", label: baseLabel }, ...filtered]
}, [companyOptions])
const loadSchedules = useCallback(async () => {
setIsLoading(true)
try {
@ -331,7 +345,7 @@ export function ReportScheduleDrawer({
<SearchableCombobox
value={companyId ?? "all"}
onValueChange={(next) => setCompanyId(next && next !== "all" ? next : null)}
options={[{ value: "all", label: "Todas as empresas" }, ...companyOptions]}
options={normalizedCompanyOptions}
placeholder="Todas as empresas"
/>
{companyId ? (
@ -396,13 +410,8 @@ export function ReportScheduleDrawer({
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="schedule-time">Horário (HH:MM)</Label>
<Input
id="schedule-time"
type="time"
value={time}
onChange={(event) => setTime(event.target.value)}
/>
<Label>Horário (HH:MM)</Label>
<TimePicker value={time} onChange={setTime} />
</div>
<div className="space-y-2">
<Label>Destinatários (emails)</Label>
@ -419,12 +428,18 @@ export function ReportScheduleDrawer({
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Agendamentos ativos</h3>
<Button variant="ghost" size="sm" onClick={() => loadSchedules()} disabled={isLoading}>
Atualizar
</Button>
</div>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Agendamentos ativos</h3>
<Button
variant="ghost"
size="sm"
className="border border-border/60"
onClick={() => loadSchedules()}
disabled={isLoading}
>
Atualizar
</Button>
</div>
{schedules.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
Nenhum agendamento criado ainda.

View file

@ -0,0 +1,128 @@
"use client"
import { useMemo, useState } from "react"
import { format, parseISO, isValid as isValidDate } from "date-fns"
import { ptBR } from "date-fns/locale"
import { Calendar as CalendarIcon, XIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useLocalTimeZone } from "@/hooks/use-local-time-zone"
import { cn } from "@/lib/utils"
type DatePickerProps = {
value?: string | Date | null
onChange?: (value: string | null) => void
placeholder?: string
disabled?: boolean
minYear?: number
maxYear?: number
className?: string
align?: "start" | "center" | "end"
allowClear?: boolean
}
function normalizeDate(value?: string | Date | null) {
if (!value) return undefined
if (value instanceof Date) {
return isValidDate(value) ? value : undefined
}
const parsed = parseISO(value)
return isValidDate(parsed) ? parsed : undefined
}
export function DatePicker({
value,
onChange,
placeholder = "Selecionar data",
disabled,
minYear = 1900,
maxYear = new Date().getFullYear() + 5,
className,
align = "start",
allowClear = true,
}: DatePickerProps) {
const [open, setOpen] = useState(false)
const timeZone = useLocalTimeZone()
const selectedDate = useMemo(() => normalizeDate(value), [value])
const startMonth = useMemo(() => new Date(minYear, 0, 1), [minYear])
const endMonth = useMemo(() => new Date(maxYear, 11, 31), [maxYear])
const handleSelect = (date: Date | undefined) => {
if (!date) {
onChange?.(null)
return
}
onChange?.(format(date, "yyyy-MM-dd"))
setOpen(false)
}
const handleClear = () => {
onChange?.(null)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={(next) => !disabled && setOpen(next)}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"w-full justify-between gap-2 text-left font-normal",
!selectedDate && "text-muted-foreground",
className
)}
disabled={disabled}
>
<span>
{selectedDate
? format(selectedDate, "dd/MM/yyyy", { locale: ptBR })
: placeholder}
</span>
{allowClear && selectedDate ? (
<XIcon
className="size-4 text-muted-foreground"
aria-label="Limpar data"
role="presentation"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
handleClear()
}}
/>
) : (
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={align}>
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleSelect}
initialFocus
captionLayout="dropdown"
startMonth={startMonth}
endMonth={endMonth}
locale={ptBR}
timeZone={timeZone}
/>
{allowClear ? (
<div className="border-t border-border/60 p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-center text-xs text-muted-foreground"
onClick={handleClear}
>
Limpar data
</Button>
</div>
) : null}
</PopoverContent>
</Popover>
)
}