feat: modernize report scheduling UI and date inputs
This commit is contained in:
parent
8cc513c532
commit
616fe42e10
10 changed files with 384 additions and 60 deletions
|
|
@ -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
25
scripts/login-sim.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
128
src/components/ui/date-picker.tsx
Normal file
128
src/components/ui/date-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue