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" "use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react" 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 { Controller, FormProvider, useFieldArray, useForm, type UseFormReturn } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { import {
@ -86,7 +87,8 @@ import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { MultiValueInput } from "@/components/ui/multi-value-input" 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 type LastAlertInfo = { createdAt: number; usagePct: number; threshold: number } | null
@ -101,6 +103,22 @@ type EditorState =
| { mode: "create" } | { mode: "create" }
| { mode: "edit"; company: NormalizedCompany } | { 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 = [ const BOARD_COLUMNS = [
{ id: "monthly", title: "Mensalistas", description: "Contratos recorrentes ou planos mensais." }, { id: "monthly", title: "Mensalistas", description: "Contratos recorrentes ou planos mensais." },
{ id: "time_bank", title: "Banco de horas", description: "Clientes com consumo controlado por horas." }, { 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>
<div className="space-y-2"> <div className="space-y-2">
<Label>Data de início</Label> <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} /> <FieldError error={fieldErrors?.startDate?.message as string | undefined} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Data de fim</Label> <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} /> <FieldError error={fieldErrors?.endDate?.message as string | undefined} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Renovação</Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label>Valor mensal/projeto</Label> <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 { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket" import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager" 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 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")) .sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
}, [companies, devices]) }, [companies, devices])
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
return companyOptions.map((company) => ({
value: company.slug,
label: company.name,
}))
}, [companyOptions])
const deviceFields = useQuery( const deviceFields = useQuery(
api.deviceFields.listForTenant, api.deviceFields.listForTenant,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
@ -2250,23 +2259,17 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<label className="text-sm font-medium text-slate-700">Empresa</label> <label className="text-sm font-medium text-slate-700">Empresa</label>
<Select <SearchableCombobox
value={newDeviceCompanySlug ?? "all"} value={newDeviceCompanySlug}
onValueChange={(value) => setNewDeviceCompanySlug(value === "all" ? null : value)} onValueChange={setNewDeviceCompanySlug}
options={companyComboboxOptions}
placeholder="Sem empresa"
searchPlaceholder="Buscar empresa..."
emptyText="Nenhuma empresa encontrada."
allowClear
clearLabel="Sem empresa"
disabled={createDeviceLoading} 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>
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor="device-serials" className="text-sm font-medium text-slate-700"> <label htmlFor="device-serials" className="text-sm font-medium text-slate-700">
@ -5595,7 +5598,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : field.type === "number" ? ( ) : field.type === "number" ? (
<Input type="number" value={value == null ? "" : String(value)} onChange={(e) => setValue(e.target.value === "" ? null : Number(e.target.value))} /> <Input type="number" value={value == null ? "" : String(value)} onChange={(e) => setValue(e.target.value === "" ? null : Number(e.target.value))} />
) : field.type === "date" ? ( ) : 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" ? ( ) : field.type === "boolean" ? (
<label className="inline-flex items-center gap-2 text-sm text-slate-700"> <label className="inline-flex items-center gap-2 text-sm text-slate-700">
<Checkbox checked={Boolean(value)} onCheckedChange={(v) => setValue(Boolean(v))} /> <Checkbox checked={Boolean(value)} onCheckedChange={(v) => setValue(Boolean(v))} />

View file

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

View file

@ -50,6 +50,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { MultiValueInput } from "@/components/ui/multi-value-input" import { MultiValueInput } from "@/components/ui/multi-value-input"
import { DatePicker } from "@/components/ui/date-picker"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -2014,21 +2015,52 @@ function ContractsEditor({
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Início Início
</Label> </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} /> <FieldError message={errors?.startDate?.message as string | undefined} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Fim Fim
</Label> </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} /> <FieldError message={errors?.endDate?.message as string | undefined} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Renovação Renovação
</Label> </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>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <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)" }, 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() { export function DashboardHero() {
const { session, convexUserId, isStaff } = useAuth() const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -135,7 +138,8 @@ export function DashboardHero() {
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold", metricBadgeClass,
"font-semibold",
newTicketsTrend.delta === null newTicketsTrend.delta === null
? "text-neutral-500" ? "text-neutral-500"
: newTicketsTrend.delta < 0 : newTicketsTrend.delta < 0
@ -170,7 +174,8 @@ export function DashboardHero() {
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold", metricBadgeClass,
"font-semibold",
inProgressTrend.delta === null inProgressTrend.delta === null
? "text-neutral-500" ? "text-neutral-500"
: inProgressTrend.delta > 0 : inProgressTrend.delta > 0
@ -213,7 +218,8 @@ export function DashboardHero() {
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold", metricBadgeClass,
"font-semibold",
responseDelta.delta === null responseDelta.delta === null
? "text-neutral-500" ? "text-neutral-500"
: responseDelta.delta > 0 : responseDelta.delta > 0
@ -247,7 +253,8 @@ export function DashboardHero() {
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold", metricBadgeClass,
"font-semibold",
resolutionInfo?.delta === null resolutionInfo?.delta === null
? "text-neutral-500" ? "text-neutral-500"
: resolutionInfo?.delta !== null && resolutionInfo.delta < 0 : resolutionInfo?.delta !== null && resolutionInfo.delta < 0
@ -370,7 +377,8 @@ function QueueSparklineCard({
<Badge <Badge
variant="outline" variant="outline"
className={cn( 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" 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 { Button } from "@/components/ui/button"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"
type BillingFilter = "all" | "avulso" | "contratado" type BillingFilter = "all" | "avulso" | "contratado"
type TimeRange = "90d" | "30d" | "7d" type TimeRange = "90d" | "30d" | "7d"
@ -26,6 +27,12 @@ type ReportsFilterToolbarProps = {
onOpenScheduler?: () => void 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({ export function ReportsFilterToolbar({
companyId, companyId,
onCompanyChange, onCompanyChange,
@ -57,15 +64,21 @@ export function ReportsFilterToolbar({
type="single" type="single"
value={billingFilter} value={billingFilter}
onValueChange={(next) => next && onBillingFilterChange?.(next as 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 Todos
</ToggleGroupItem> </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 Somente avulsos
</ToggleGroupItem> </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 Somente contratados
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
@ -74,17 +87,15 @@ export function ReportsFilterToolbar({
type="single" type="single"
value={timeRange} value={timeRange}
onValueChange={(value) => value && onTimeRangeChange(value as TimeRange)} onValueChange={(value) => value && onTimeRangeChange(value as TimeRange)}
variant="outline" 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"
size="lg"
className="flex rounded-2xl border border-border/60"
> >
<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 90 dias
</ToggleGroupItem> </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 30 dias
</ToggleGroupItem> </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 7 dias
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
@ -28,6 +28,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { TimePicker } from "@/components/ui/time-picker"
type SerializableRun = { type SerializableRun = {
id: string id: string
@ -118,6 +119,19 @@ export function ReportScheduleDrawer({
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [schedules, setSchedules] = useState<SerializableSchedule[]>([]) 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 () => { const loadSchedules = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
try { try {
@ -331,7 +345,7 @@ export function ReportScheduleDrawer({
<SearchableCombobox <SearchableCombobox
value={companyId ?? "all"} value={companyId ?? "all"}
onValueChange={(next) => setCompanyId(next && next !== "all" ? next : null)} onValueChange={(next) => setCompanyId(next && next !== "all" ? next : null)}
options={[{ value: "all", label: "Todas as empresas" }, ...companyOptions]} options={normalizedCompanyOptions}
placeholder="Todas as empresas" placeholder="Todas as empresas"
/> />
{companyId ? ( {companyId ? (
@ -396,13 +410,8 @@ export function ReportScheduleDrawer({
</div> </div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="schedule-time">Horário (HH:MM)</Label> <Label>Horário (HH:MM)</Label>
<Input <TimePicker value={time} onChange={setTime} />
id="schedule-time"
type="time"
value={time}
onChange={(event) => setTime(event.target.value)}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Destinatários (emails)</Label> <Label>Destinatários (emails)</Label>
@ -421,7 +430,13 @@ export function ReportScheduleDrawer({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Agendamentos ativos</h3> <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 Atualizar
</Button> </Button>
</div> </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>
)
}