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"
|
"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>
|
||||||
|
|
|
||||||
|
|
@ -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))} />
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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