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

@ -0,0 +1,50 @@
-- CreateTable
CREATE TABLE "ReportExportSchedule" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"reportKeys" JSONB NOT NULL,
"range" TEXT NOT NULL DEFAULT '30d',
"companyId" TEXT,
"companyName" TEXT,
"format" TEXT NOT NULL DEFAULT 'xlsx',
"frequency" TEXT NOT NULL,
"dayOfWeek" INTEGER,
"dayOfMonth" INTEGER,
"hour" INTEGER NOT NULL DEFAULT 8,
"minute" INTEGER NOT NULL DEFAULT 0,
"timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo',
"recipients" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"lastRunAt" DATETIME,
"nextRunAt" DATETIME,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "ReportExportRun" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"startedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" DATETIME,
"error" TEXT,
"artifacts" JSONB,
CONSTRAINT "ReportExportRun_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "ReportExportSchedule" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "ReportExportSchedule_tenantId_status_idx" ON "ReportExportSchedule"("tenantId", "status");
-- CreateIndex
CREATE INDEX "ReportExportSchedule_tenantId_nextRunAt_idx" ON "ReportExportSchedule"("tenantId", "nextRunAt");
-- CreateIndex
CREATE INDEX "ReportExportRun_tenantId_status_idx" ON "ReportExportRun"("tenantId", "status");
-- CreateIndex
CREATE INDEX "ReportExportRun_tenantId_scheduleId_idx" ON "ReportExportRun"("tenantId", "scheduleId");

25
scripts/login-sim.ts Normal file
View file

@ -0,0 +1,25 @@
import { auth } from "../src/lib/auth"
async function simulateLogin() {
const email = process.env.TEST_LOGIN_EMAIL ?? "admin@sistema.dev"
const password = process.env.TEST_LOGIN_PASSWORD ?? "admin123"
const result = await auth.api.signInEmail({
body: {
email,
password,
rememberMe: true,
},
returnHeaders: true,
})
console.log("HTTP", result.response ? 200 : "unknown")
console.log("Session token", result.response?.token)
console.log("User", result.response?.user)
console.log("Cookies", Array.from(result.headers.entries()))
}
simulateLogin().catch((error) => {
console.error("Failed to simulate login:", error)
process.exit(1)
})

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>
@ -421,7 +430,13 @@ export function ReportScheduleDrawer({
<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}>
<Button
variant="ghost"
size="sm"
className="border border-border/60"
onClick={() => loadSchedules()}
disabled={isLoading}
>
Atualizar
</Button>
</div>

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>
)
}