Refine admin companies layout and relax provisioning schema
This commit is contained in:
parent
2cba553efa
commit
43230e0310
4 changed files with 573 additions and 142 deletions
|
|
@ -20,7 +20,7 @@ export default defineSchema({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
slug: v.string(),
|
slug: v.string(),
|
||||||
provisioningCode: v.string(),
|
provisioningCode: v.optional(v.string()),
|
||||||
isAvulso: v.optional(v.boolean()),
|
isAvulso: v.optional(v.boolean()),
|
||||||
contractedHoursPerMonth: v.optional(v.number()),
|
contractedHoursPerMonth: v.optional(v.number()),
|
||||||
cnpj: v.optional(v.string()),
|
cnpj: v.optional(v.string()),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export default async function AdminCompaniesPage() {
|
||||||
tenantId: c.tenantId,
|
tenantId: c.tenantId,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
slug: c.slug,
|
slug: c.slug,
|
||||||
provisioningCode: c.provisioningCode,
|
provisioningCode: c.provisioningCode ?? null,
|
||||||
isAvulso: Boolean(extra.isAvulso ?? false),
|
isAvulso: Boolean(extra.isAvulso ?? false),
|
||||||
contractedHoursPerMonth: extra.contractedHoursPerMonth ?? null,
|
contractedHoursPerMonth: extra.contractedHoursPerMonth ?? null,
|
||||||
cnpj: c.cnpj ?? null,
|
cnpj: c.cnpj ?? null,
|
||||||
|
|
@ -34,7 +34,7 @@ export default async function AdminCompaniesPage() {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
|
<div className="mx-auto w-full max-w-7xl px-4 md:px-8 lg:px-10">
|
||||||
<AdminCompaniesManager initialCompanies={companies} />
|
<AdminCompaniesManager initialCompanies={companies} />
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,40 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
|
||||||
|
import { formatDistanceToNow } from "date-fns"
|
||||||
|
import { ptBR } from "date-fns/locale"
|
||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconBuildingSkyscraper,
|
||||||
|
IconClock,
|
||||||
|
IconCopy,
|
||||||
|
IconDotsVertical,
|
||||||
|
IconCheck,
|
||||||
|
IconPencil,
|
||||||
|
IconRefresh,
|
||||||
|
IconSearch,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconSwitchHorizontal,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
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 { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -17,13 +44,16 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
|
||||||
type Company = {
|
type Company = {
|
||||||
id: string
|
id: string
|
||||||
tenantId: string
|
tenantId: string
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
provisioningCode: string
|
provisioningCode: string | null
|
||||||
isAvulso: boolean
|
isAvulso: boolean
|
||||||
contractedHoursPerMonth?: number | null
|
contractedHoursPerMonth?: number | null
|
||||||
cnpj: string | null
|
cnpj: string | null
|
||||||
|
|
@ -34,21 +64,24 @@ type Company = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
|
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
|
||||||
const [companies, setCompanies] = useState<Company[]>(initialCompanies)
|
const [companies, setCompanies] = useState<Company[]>(() => initialCompanies ?? [])
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [form, setForm] = useState<Partial<Company>>({})
|
const [form, setForm] = useState<Partial<Company>>({})
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [lastAlerts, setLastAlerts] = useState<Record<string, { createdAt: number; usagePct: number; threshold: number } | null>>({})
|
const [lastAlerts, setLastAlerts] = useState<Record<string, { createdAt: number; usagePct: number; threshold: number } | null>>({})
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const resetForm = () => setForm({})
|
const resetForm = () => setForm({})
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const r = await fetch("/api/admin/companies", { credentials: "include" })
|
const r = await fetch("/api/admin/companies", { credentials: "include" })
|
||||||
const json = (await r.json()) as { companies: Company[] }
|
const json = (await r.json()) as { companies?: Company[] }
|
||||||
setCompanies(json.companies)
|
const nextCompanies = Array.isArray(json.companies) ? json.companies : []
|
||||||
void loadLastAlerts(json.companies)
|
setCompanies(nextCompanies)
|
||||||
|
void loadLastAlerts(nextCompanies)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(c: Company) {
|
function handleEdit(c: Company) {
|
||||||
|
|
@ -59,19 +92,6 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyProvisioningCode = useCallback(async (code: string) => {
|
|
||||||
try {
|
|
||||||
if (navigator?.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(code)
|
|
||||||
toast.success("Código copiado para a área de transferência")
|
|
||||||
} else {
|
|
||||||
throw new Error("Clipboard indisponível")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Não foi possível copiar o código. Copie manualmente.")
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadLastAlerts = useCallback(async (list: Company[] = companies) => {
|
const loadLastAlerts = useCallback(async (list: Company[] = companies) => {
|
||||||
if (!list || list.length === 0) return
|
if (!list || list.length === 0) return
|
||||||
const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") })
|
const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") })
|
||||||
|
|
@ -185,27 +205,74 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
|
|
||||||
const editingCompanyName = useMemo(() => companies.find((company) => company.id === editingId)?.name ?? null, [companies, editingId])
|
const editingCompanyName = useMemo(() => companies.find((company) => company.id === editingId)?.name ?? null, [companies, editingId])
|
||||||
const deleteTarget = useMemo(() => companies.find((company) => company.id === deleteId) ?? null, [companies, deleteId])
|
const deleteTarget = useMemo(() => companies.find((company) => company.id === deleteId) ?? null, [companies, deleteId])
|
||||||
|
const filteredCompanies = useMemo(() => {
|
||||||
|
const query = searchTerm.trim().toLowerCase()
|
||||||
|
if (!query) return companies
|
||||||
|
return companies.filter((company) => {
|
||||||
|
return [
|
||||||
|
company.name,
|
||||||
|
company.slug,
|
||||||
|
company.domain,
|
||||||
|
company.cnpj,
|
||||||
|
company.phone,
|
||||||
|
company.description,
|
||||||
|
].some((value) => value?.toLowerCase().includes(query))
|
||||||
|
})
|
||||||
|
}, [companies, searchTerm])
|
||||||
|
const hasCompanies = filteredCompanies.length > 0
|
||||||
|
|
||||||
|
const emptyContent = (
|
||||||
|
<Empty className="mx-auto max-w-md border-none bg-background/60 shadow-none">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<IconBuildingSkyscraper className="size-5" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>Nenhuma empresa encontrada</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
{searchTerm
|
||||||
|
? "Nenhum cadastro corresponde à busca realizada. Ajuste os termos e tente novamente."
|
||||||
|
: "Cadastre uma empresa para começar a gerenciar clientes por aqui."}
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
{searchTerm ? (
|
||||||
|
<EmptyContent>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setSearchTerm("")}>
|
||||||
|
Limpar busca
|
||||||
|
</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
) : null}
|
||||||
|
</Empty>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="border-slate-200">
|
<TooltipProvider delayDuration={120}>
|
||||||
<CardHeader>
|
<Card className="border-slate-200">
|
||||||
<CardTitle>{editingId ? `Editar empresa${editingCompanyName ? ` · ${editingCompanyName}` : ""}` : "Nova empresa"}</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>
|
<CardTitle>{editingId ? `Editar empresa${editingCompanyName ? ` · ${editingCompanyName}` : ""}` : "Nova empresa"}</CardTitle>
|
||||||
{editingId
|
<CardDescription>
|
||||||
? "Atualize os dados cadastrais e as informações de faturamento do cliente selecionado."
|
{editingId
|
||||||
: "Cadastre um cliente/empresa e defina se é avulso."}
|
? "Atualize os dados cadastrais e as informações de faturamento do cliente selecionado."
|
||||||
</CardDescription>
|
: "Cadastre um cliente/empresa e defina se é avulso."}
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Nome</Label>
|
<Label>Nome</Label>
|
||||||
<Input value={form.name ?? ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
|
<Input
|
||||||
|
value={form.name ?? ""}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="Nome da empresa ou apelido interno"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Slug</Label>
|
<Label>Slug</Label>
|
||||||
<Input value={form.slug ?? ""} onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))} />
|
<Input
|
||||||
|
value={form.slug ?? ""}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))}
|
||||||
|
placeholder="empresa-exemplo"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label>Descrição</Label>
|
<Label>Descrição</Label>
|
||||||
|
|
@ -213,19 +280,35 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>CNPJ</Label>
|
<Label>CNPJ</Label>
|
||||||
<Input value={form.cnpj ?? ""} onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))} />
|
<Input
|
||||||
|
value={form.cnpj ?? ""}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))}
|
||||||
|
placeholder="00.000.000/0000-00"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Domínio</Label>
|
<Label>Domínio</Label>
|
||||||
<Input value={form.domain ?? ""} onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))} />
|
<Input
|
||||||
|
value={form.domain ?? ""}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))}
|
||||||
|
placeholder="empresa.com.br"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Telefone</Label>
|
<Label>Telefone</Label>
|
||||||
<Input value={form.phone ?? ""} onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))} />
|
<Input
|
||||||
|
value={form.phone ?? ""}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))}
|
||||||
|
placeholder="(+55) 11 99999-9999"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label>Endereço</Label>
|
<Label>Endereço</Label>
|
||||||
<Input value={form.address ?? ""} onChange={(e) => setForm((p) => ({ ...p, address: e.target.value }))} />
|
<Input
|
||||||
|
value={form.address ?? ""}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, address: e.target.value }))}
|
||||||
|
placeholder="Rua, número, bairro, cidade/UF"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Horas contratadas/mês</Label>
|
<Label>Horas contratadas/mês</Label>
|
||||||
|
|
@ -235,104 +318,361 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
step="0.25"
|
step="0.25"
|
||||||
value={form.contractedHoursPerMonth ?? ""}
|
value={form.contractedHoursPerMonth ?? ""}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, contractedHoursPerMonth: e.target.value === "" ? undefined : Number(e.target.value) }))}
|
onChange={(e) => setForm((p) => ({ ...p, contractedHoursPerMonth: e.target.value === "" ? undefined : Number(e.target.value) }))}
|
||||||
|
placeholder="Ex.: 40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:col-span-2">
|
<div className="flex items-center gap-2 md:col-span-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={Boolean(form.isAvulso ?? false)}
|
checked={Boolean(form.isAvulso ?? false)}
|
||||||
onCheckedChange={(v) => setForm((p) => ({ ...p, isAvulso: Boolean(v) }))}
|
onCheckedChange={(v) => setForm((p) => ({ ...p, isAvulso: Boolean(v) }))}
|
||||||
id="is-avulso"
|
id="is-avulso"
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="is-avulso">Cliente avulso?</Label>
|
<Label htmlFor="is-avulso">Cliente avulso?</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Button type="submit" disabled={isPending}>{editingId ? "Salvar alterações" : "Cadastrar empresa"}</Button>
|
<Button type="submit" disabled={isPending}>{editingId ? "Salvar alterações" : "Cadastrar empresa"}</Button>
|
||||||
{editingId ? (
|
{editingId ? (
|
||||||
<Button type="button" variant="ghost" className="ml-2" onClick={() => { resetForm(); setEditingId(null) }}>
|
<Button type="button" variant="ghost" className="ml-2" onClick={() => { resetForm(); setEditingId(null) }}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200/80 shadow-none">
|
||||||
<CardHeader>
|
<CardHeader className="gap-4 border-b border-slate-100 py-6 dark:border-slate-800/60">
|
||||||
<CardTitle>Empresas cadastradas</CardTitle>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<CardDescription>Gerencie empresas e o status de cliente avulso.</CardDescription>
|
<div className="space-y-1">
|
||||||
</CardHeader>
|
<CardTitle className="text-xl font-semibold tracking-tight">Empresas cadastradas</CardTitle>
|
||||||
<CardContent>
|
<CardDescription>Gerencie empresas e o status de cliente avulso.</CardDescription>
|
||||||
<Table>
|
</div>
|
||||||
<TableHeader>
|
<Badge variant="outline" className="self-start text-xs font-medium">
|
||||||
<TableRow>
|
{filteredCompanies.length} {filteredCompanies.length === 1 ? "empresa" : "empresas"}
|
||||||
<TableHead>Nome</TableHead>
|
</Badge>
|
||||||
<TableHead>Slug</TableHead>
|
</div>
|
||||||
<TableHead>Código</TableHead>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<TableHead>Avulso</TableHead>
|
<div className="relative w-full sm:max-w-xs">
|
||||||
<TableHead>Domínio</TableHead>
|
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
|
||||||
<TableHead>Telefone</TableHead>
|
<Input
|
||||||
<TableHead>CNPJ</TableHead>
|
value={searchTerm}
|
||||||
<TableHead>Último alerta</TableHead>
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
<TableHead>Ações</TableHead>
|
placeholder="Buscar por nome, slug ou domínio..."
|
||||||
</TableRow>
|
className="pl-9"
|
||||||
</TableHeader>
|
/>
|
||||||
<TableBody>
|
</div>
|
||||||
{companies.map((c) => (
|
<Button
|
||||||
<TableRow key={c.id}>
|
type="button"
|
||||||
<TableCell className="font-medium">{c.name}</TableCell>
|
variant="ghost"
|
||||||
<TableCell>{c.slug}</TableCell>
|
size="sm"
|
||||||
<TableCell>
|
className="sm:self-start"
|
||||||
<div className="flex items-center gap-2">
|
disabled={isPending}
|
||||||
<code
|
onClick={() => startTransition(() => { void refresh() })}
|
||||||
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-mono text-neutral-700"
|
>
|
||||||
title={c.provisioningCode}
|
<IconRefresh className={cn("size-4", isPending && "animate-spin")} />
|
||||||
|
Atualizar lista
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{hasCompanies ? (
|
||||||
|
filteredCompanies.map((company) => {
|
||||||
|
const alertInfo = lastAlerts[company.slug] ?? null
|
||||||
|
const usagePct = alertInfo?.usagePct ?? 0
|
||||||
|
const threshold = alertInfo?.threshold ?? 0
|
||||||
|
const isThresholdExceeded = Boolean(alertInfo) && usagePct >= threshold
|
||||||
|
const lastAlertDistance = alertInfo
|
||||||
|
? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR })
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={company.id}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||||
>
|
>
|
||||||
{c.provisioningCode.slice(0, 10)}…
|
<div className="flex items-start justify-between gap-3">
|
||||||
</code>
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Avatar className="bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-100">
|
||||||
size="sm"
|
<AvatarFallback className="bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-100">
|
||||||
variant="outline"
|
{getInitials(company.name)}
|
||||||
onClick={() => handleCopyProvisioningCode(c.provisioningCode)}
|
</AvatarFallback>
|
||||||
>
|
</Avatar>
|
||||||
Copiar
|
<div className="space-y-1">
|
||||||
</Button>
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-50">{company.name}</p>
|
||||||
</div>
|
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">{company.slug}</p>
|
||||||
</TableCell>
|
{company.domain ? (
|
||||||
<TableCell>
|
<p className="text-[11px] text-muted-foreground">{company.domain}</p>
|
||||||
<Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}>
|
) : null}
|
||||||
{c.isAvulso ? "Sim" : "Não"}
|
{company.description ? (
|
||||||
</Button>
|
<p className="text-[11px] text-muted-foreground">{company.description}</p>
|
||||||
</TableCell>
|
) : null}
|
||||||
<TableCell>{c.domain ?? "—"}</TableCell>
|
</div>
|
||||||
<TableCell>{c.phone ?? "—"}</TableCell>
|
</div>
|
||||||
<TableCell>{c.cnpj ?? "—"}</TableCell>
|
<DropdownMenu>
|
||||||
<TableCell>
|
<DropdownMenuTrigger asChild>
|
||||||
{lastAlerts[c.slug]
|
<Button variant="ghost" size="icon">
|
||||||
? `${new Date(lastAlerts[c.slug]!.createdAt).toLocaleString("pt-BR")}`
|
<IconDotsVertical className="size-4" />
|
||||||
: "—"}
|
<span className="sr-only">Abrir menu de ações</span>
|
||||||
</TableCell>
|
</Button>
|
||||||
<TableCell>
|
</DropdownMenuTrigger>
|
||||||
<div className="flex flex-wrap gap-2">
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
<Button size="sm" variant="outline" onClick={() => handleEdit(c)}>
|
<DropdownMenuItem onSelect={() => handleEdit(company)}>
|
||||||
Editar
|
<IconPencil className="size-4" />
|
||||||
</Button>
|
Editar empresa
|
||||||
<Button
|
</DropdownMenuItem>
|
||||||
size="sm"
|
<DropdownMenuItem onSelect={() => void toggleAvulso(company)}>
|
||||||
variant="ghost"
|
<IconSwitchHorizontal className="size-4" />
|
||||||
className="text-red-600 transition-colors hover:bg-red-500/10"
|
{company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"}
|
||||||
onClick={() => setDeleteId(c.id)}
|
</DropdownMenuItem>
|
||||||
>
|
<DropdownMenuSeparator />
|
||||||
Remover
|
<DropdownMenuItem
|
||||||
</Button>
|
variant="destructive"
|
||||||
</div>
|
onSelect={() => setDeleteId(company.id)}
|
||||||
</TableCell>
|
>
|
||||||
</TableRow>
|
<IconTrash className="size-4" />
|
||||||
))}
|
Remover empresa
|
||||||
</TableBody>
|
</DropdownMenuItem>
|
||||||
</Table>
|
</DropdownMenuContent>
|
||||||
</CardContent>
|
</DropdownMenu>
|
||||||
</Card>
|
</div>
|
||||||
|
<div className="mt-3 grid gap-1 text-xs text-muted-foreground">
|
||||||
|
{company.phone ? <span>{company.phone}</span> : null}
|
||||||
|
{company.address ? <span>{company.address}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ProvisioningCodeCard code={company.provisioningCode} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
|
<Badge
|
||||||
|
variant={company.isAvulso ? "default" : "outline"}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 items-center rounded-full px-3 text-xs font-medium",
|
||||||
|
company.isAvulso ? "bg-sky-500/10 text-sky-600 dark:bg-sky-500/20 dark:text-sky-200" : "border-slate-200 text-slate-600 dark:border-slate-700 dark:text-slate-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{company.isAvulso ? "Cliente avulso" : "Recorrente"}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex h-8 items-center gap-1 rounded-full border-slate-200 px-3"
|
||||||
|
onClick={() => void toggleAvulso(company)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<IconSwitchHorizontal className="size-4" />
|
||||||
|
{company.isAvulso ? "Marcar recorrente" : "Marcar avulso"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{alertInfo ? (
|
||||||
|
<>
|
||||||
|
<IconClock className="size-3" />
|
||||||
|
<span>{lastAlertDistance}</span>
|
||||||
|
<span>· Consumo {Math.round(usagePct)}% / limite {threshold}%</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconShieldCheck className="text-emerald-500 size-3" />
|
||||||
|
<span>Sem alertas recentes</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
emptyContent
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table className="min-w-full table-fixed text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-slate-100/80 dark:border-slate-800/60">
|
||||||
|
<TableHead className="w-[30%] min-w-[220px] pl-6 text-slate-500 dark:text-slate-300">Empresa</TableHead>
|
||||||
|
<TableHead className="w-[22%] min-w-[180px] text-slate-500 dark:text-slate-300">Provisionamento</TableHead>
|
||||||
|
<TableHead className="w-[18%] min-w-[160px] text-slate-500 dark:text-slate-300">Cliente avulso</TableHead>
|
||||||
|
<TableHead className="w-[20%] min-w-[170px] text-slate-500 dark:text-slate-300">Uso e alertas</TableHead>
|
||||||
|
<TableHead className="w-[10%] min-w-[90px] pr-6 text-right text-slate-500 dark:text-slate-300">Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{hasCompanies ? (
|
||||||
|
filteredCompanies.map((company) => {
|
||||||
|
const alertInfo = lastAlerts[company.slug] ?? null
|
||||||
|
const usagePct = alertInfo?.usagePct ?? 0
|
||||||
|
const threshold = alertInfo?.threshold ?? 0
|
||||||
|
const isThresholdExceeded = Boolean(alertInfo) && usagePct >= threshold
|
||||||
|
const lastAlertDistance = alertInfo
|
||||||
|
? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR })
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={company.id}
|
||||||
|
className="border-slate-100/60 transition-colors hover:bg-slate-50 dark:border-slate-800/60 dark:hover:bg-slate-900/40"
|
||||||
|
>
|
||||||
|
<TableCell className="min-w-[240px] pl-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Avatar className="bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-100">
|
||||||
|
<AvatarFallback className="bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-100">
|
||||||
|
{getInitials(company.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-50">
|
||||||
|
{company.name}
|
||||||
|
</span>
|
||||||
|
{company.cnpj ? (
|
||||||
|
<Badge variant="outline" className="border-slate-200 bg-white text-[11px] font-medium uppercase tracking-wide dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
CNPJ
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{typeof company.contractedHoursPerMonth === "number" ? (
|
||||||
|
<Badge variant="outline" className="border-slate-200 bg-white text-[11px] font-medium dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
{company.contractedHoursPerMonth}h/mês
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="text-[12px] font-medium text-slate-500 dark:text-slate-400">
|
||||||
|
{company.slug}
|
||||||
|
</span>
|
||||||
|
{company.domain ? (
|
||||||
|
<>
|
||||||
|
<span className="bg-slate-300/70 dark:bg-slate-700/70 block size-1 rounded-full" />
|
||||||
|
<span className="truncate">{company.domain}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{company.phone ? <span>{company.phone}</span> : null}
|
||||||
|
{company.phone && company.address ? (
|
||||||
|
<span className="bg-slate-300/70 dark:bg-slate-700/70 block size-1 rounded-full" />
|
||||||
|
) : null}
|
||||||
|
{company.address ? <span className="truncate">{company.address}</span> : null}
|
||||||
|
{!company.phone && !company.address && company.description ? (
|
||||||
|
<span className="truncate text-muted-foreground/70">{company.description}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-middle pr-10">
|
||||||
|
<ProvisioningCodeCard code={company.provisioningCode} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="pl-6 pr-4 align-middle">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge
|
||||||
|
variant={company.isAvulso ? "default" : "outline"}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 items-center rounded-full px-3 text-xs font-medium",
|
||||||
|
company.isAvulso ? "bg-sky-500/10 text-sky-600 dark:bg-sky-500/20 dark:text-sky-200" : "border-slate-200 text-slate-600 dark:border-slate-700 dark:text-slate-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{company.isAvulso ? "Cliente avulso" : "Recorrente"}
|
||||||
|
</Badge>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-transparent hover:border-slate-200 dark:hover:border-slate-700"
|
||||||
|
onClick={() => void toggleAvulso(company)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<IconSwitchHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">Alternar cliente avulso</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="pr-4 align-middle">
|
||||||
|
{alertInfo ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<IconClock className="size-3" />
|
||||||
|
<span>{lastAlertDistance}</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={usagePct}
|
||||||
|
className="bg-slate-100 dark:bg-slate-800"
|
||||||
|
indicatorClassName={cn(
|
||||||
|
"bg-primary",
|
||||||
|
isThresholdExceeded && "bg-amber-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-slate-300">
|
||||||
|
{isThresholdExceeded ? (
|
||||||
|
<IconAlertTriangle className="text-amber-500 size-3" />
|
||||||
|
) : (
|
||||||
|
<IconShieldCheck className="text-emerald-500 size-3" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
Consumo {Math.round(usagePct)}% · Limite {threshold}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<IconShieldCheck className="text-emerald-500 size-3" />
|
||||||
|
<span>Sem alertas recentes</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="pr-6 text-right align-top">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="ml-auto">
|
||||||
|
<IconDotsVertical className="size-4" />
|
||||||
|
<span className="sr-only">Abrir menu de ações</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
<DropdownMenuItem onSelect={() => handleEdit(company)}>
|
||||||
|
<IconPencil className="size-4" />
|
||||||
|
Editar empresa
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => void toggleAvulso(company)}>
|
||||||
|
<IconSwitchHorizontal className="size-4" />
|
||||||
|
{company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => setDeleteId(company.id)}
|
||||||
|
>
|
||||||
|
<IconTrash className="size-4" />
|
||||||
|
Remover empresa
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-12">
|
||||||
|
{emptyContent}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deleteId !== null}
|
open={deleteId !== null}
|
||||||
|
|
@ -371,3 +711,89 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitials(value: string) {
|
||||||
|
const cleaned = value.trim()
|
||||||
|
if (!cleaned) return "?"
|
||||||
|
const pieces = cleaned
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((piece) => piece.charAt(0).toUpperCase())
|
||||||
|
if (pieces.length === 0) {
|
||||||
|
return cleaned.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
return pieces.join("") || cleaned.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProvisioningCodeCard({ code }: { code: string | null }) {
|
||||||
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const handleCopied = useCallback(() => {
|
||||||
|
if (resetTimerRef.current) {
|
||||||
|
clearTimeout(resetTimerRef.current)
|
||||||
|
}
|
||||||
|
setIsCopied(true)
|
||||||
|
resetTimerRef.current = setTimeout(() => setIsCopied(false), 2600)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
if (!code) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code)
|
||||||
|
handleCopied()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Falha ao copiar código de provisionamento", error)
|
||||||
|
}
|
||||||
|
}, [code, handleCopied])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (resetTimerRef.current) {
|
||||||
|
clearTimeout(resetTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
Nenhum código provisionado no momento.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-block space-y-1.5 mr-4 md:mr-6">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative w-full max-w-full rounded-md border border-sidebar-border bg-sidebar-accent px-3 py-1 transition-transform duration-200 hover:-translate-y-0.5 hover:border-sidebar-ring dark:bg-sidebar-accent md:max-w-[16.5rem]",
|
||||||
|
isCopied && "ring-1 ring-sidebar-ring"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
className="ml-auto border border-transparent text-sidebar-accent-foreground hover:border-sidebar-ring/40 hover:text-sidebar-accent-foreground dark:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
{isCopied ? <IconCheck className="size-4" /> : <IconCopy className="size-4" />}
|
||||||
|
<span className="sr-only">{isCopied ? "Código copiado" : "Copiar código"}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isCopied ? (
|
||||||
|
<div className="flex w-full items-center justify-center gap-2 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
<IconCheck className="size-3.5" />
|
||||||
|
<span>Código copiado para a área de transferência</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,15 +136,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const message =
|
const message =
|
||||||
(payload && typeof payload.error === "string" && payload.error.trim()) || fallbackMessage
|
(payload && typeof payload.error === "string" && payload.error.trim()) || fallbackMessage
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const debugPayload = {
|
// For 401/403/404 we silently ignore: most browsers hitting the
|
||||||
status: response.status,
|
// web app are not machines and won't have a machine session.
|
||||||
message,
|
if (response.status !== 401 && response.status !== 403 && response.status !== 404) {
|
||||||
payload,
|
const debugPayload = {
|
||||||
timestamp: new Date().toISOString(),
|
status: response.status,
|
||||||
}
|
message,
|
||||||
console.error("[auth] machine context request failed", debugPayload)
|
payload,
|
||||||
if (typeof window !== "undefined") {
|
timestamp: new Date().toISOString(),
|
||||||
window.__machineContextDebug = debugPayload
|
}
|
||||||
|
// Use warn to reduce noise in production consoles
|
||||||
|
console.warn("[auth] machine context request failed", debugPayload)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.__machineContextDebug = debugPayload
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setMachineContext(null)
|
setMachineContext(null)
|
||||||
setMachineContextError({
|
setMachineContextError({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue