feat: CSV exports, PDF improvements, play internal/external with hour split, roles cleanup, admin companies with 'Cliente avulso', ticket list spacing/alignment fixes, status translations and mappings
This commit is contained in:
parent
addd4ce6e8
commit
3bafcc5a0a
45 changed files with 1401 additions and 256 deletions
|
|
@ -95,9 +95,26 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [companies, setCompanies] = useState<Array<{ id: string; name: string }>>([])
|
||||
const [linkEmail, setLinkEmail] = useState("")
|
||||
const [linkCompanyId, setLinkCompanyId] = useState("")
|
||||
|
||||
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
|
||||
|
||||
// load companies for association
|
||||
useMemo(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await fetch("/api/admin/companies", { credentials: "include" })
|
||||
const j = await r.json()
|
||||
const items = (j.companies ?? []).map((c: any) => ({ id: c.id as string, name: c.name as string }))
|
||||
setCompanies(items)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!email || !email.includes("@")) {
|
||||
|
|
@ -238,9 +255,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<SelectContent>
|
||||
{normalizedRoles.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item === "customer"
|
||||
? "Cliente"
|
||||
: item === "admin"
|
||||
{item === "admin"
|
||||
? "Administrador"
|
||||
: item === "manager"
|
||||
? "Gestor"
|
||||
|
|
@ -294,6 +309,63 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vincular usuário a empresa</CardTitle>
|
||||
<CardDescription>Associe um colaborador à sua empresa (usado para escopo de gestores e relatórios).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (!linkEmail || !linkCompanyId) {
|
||||
toast.error("Informe e-mail e empresa")
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
toast.loading("Vinculando...", { id: "assign-company" })
|
||||
try {
|
||||
const r = await fetch("/api/admin/users/assign-company", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: linkEmail, companyId: linkCompanyId }),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!r.ok) throw new Error("failed")
|
||||
toast.success("Usuário vinculado à empresa!", { id: "assign-company" })
|
||||
} catch {
|
||||
toast.error("Não foi possível vincular", { id: "assign-company" })
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label>E-mail do usuário</Label>
|
||||
<Input value={linkEmail} onChange={(e) => setLinkEmail(e.target.value)} placeholder="colaborador@empresa.com" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Empresa</Label>
|
||||
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={isPending}>Vincular</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Convites emitidos</CardTitle>
|
||||
|
|
|
|||
213
src/components/admin/companies/admin-companies-manager.tsx
Normal file
213
src/components/admin/companies/admin-companies-manager.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState, useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
type Company = {
|
||||
id: string
|
||||
tenantId: string
|
||||
name: string
|
||||
slug: string
|
||||
isAvulso: boolean
|
||||
cnpj: string | null
|
||||
domain: string | null
|
||||
phone: string | null
|
||||
description: string | null
|
||||
address: string | null
|
||||
}
|
||||
|
||||
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
|
||||
const [companies, setCompanies] = useState<Company[]>(initialCompanies)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [form, setForm] = useState<Partial<Company>>({})
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const resetForm = () => setForm({})
|
||||
|
||||
async function refresh() {
|
||||
const r = await fetch("/api/admin/companies", { credentials: "include" })
|
||||
const json = (await r.json()) as { companies: Company[] }
|
||||
setCompanies(json.companies)
|
||||
}
|
||||
|
||||
function handleEdit(c: Company) {
|
||||
setEditingId(c.id)
|
||||
setForm({ ...c })
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const payload = {
|
||||
name: form.name?.trim(),
|
||||
slug: form.slug?.trim(),
|
||||
isAvulso: Boolean(form.isAvulso ?? false),
|
||||
cnpj: form.cnpj?.trim() || null,
|
||||
domain: form.domain?.trim() || null,
|
||||
phone: form.phone?.trim() || null,
|
||||
description: form.description?.trim() || null,
|
||||
address: form.address?.trim() || null,
|
||||
}
|
||||
if (!payload.name || !payload.slug) {
|
||||
toast.error("Informe nome e slug válidos")
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
toast.loading(editingId ? "Atualizando empresa..." : "Criando empresa...", { id: "companies" })
|
||||
try {
|
||||
if (editingId) {
|
||||
const r = await fetch(`/api/admin/companies/${editingId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!r.ok) throw new Error("update_failed")
|
||||
} else {
|
||||
const r = await fetch(`/api/admin/companies`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!r.ok) throw new Error("create_failed")
|
||||
}
|
||||
await refresh()
|
||||
resetForm()
|
||||
setEditingId(null)
|
||||
toast.success(editingId ? "Empresa atualizada" : "Empresa criada", { id: "companies" })
|
||||
} catch {
|
||||
toast.error("Não foi possível salvar", { id: "companies" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function toggleAvulso(c: Company) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/admin/companies/${c.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isAvulso: !c.isAvulso }),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!r.ok) throw new Error("toggle_failed")
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error("Não foi possível atualizar o cliente avulso")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Nova empresa</CardTitle>
|
||||
<CardDescription>Cadastre um cliente/empresa e defina se é avulso.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Nome</Label>
|
||||
<Input value={form.name ?? ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Slug</Label>
|
||||
<Input value={form.slug ?? ""} onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>CNPJ</Label>
|
||||
<Input value={form.cnpj ?? ""} onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Domínio</Label>
|
||||
<Input value={form.domain ?? ""} onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Telefone</Label>
|
||||
<Input value={form.phone ?? ""} onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Endereço</Label>
|
||||
<Input value={form.address ?? ""} onChange={(e) => setForm((p) => ({ ...p, address: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:col-span-2">
|
||||
<Checkbox
|
||||
checked={Boolean(form.isAvulso ?? false)}
|
||||
onCheckedChange={(v) => setForm((p) => ({ ...p, isAvulso: Boolean(v) }))}
|
||||
id="is-avulso"
|
||||
/>
|
||||
<Label htmlFor="is-avulso">Cliente avulso?</Label>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" disabled={isPending}>{editingId ? "Salvar alterações" : "Cadastrar empresa"}</Button>
|
||||
{editingId ? (
|
||||
<Button type="button" variant="ghost" className="ml-2" onClick={() => { resetForm(); setEditingId(null) }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Empresas cadastradas</CardTitle>
|
||||
<CardDescription>Gerencie empresas e o status de cliente avulso.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>Avulso</TableHead>
|
||||
<TableHead>Domínio</TableHead>
|
||||
<TableHead>Telefone</TableHead>
|
||||
<TableHead>CNPJ</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell>{c.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}>
|
||||
{c.isAvulso ? "Sim" : "Não"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{c.domain ?? "—"}</TableCell>
|
||||
<TableCell>{c.phone ?? "—"}</TableCell>
|
||||
<TableCell>{c.cnpj ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="outline" onClick={() => handleEdit(c)}>
|
||||
Editar
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ import { useAuth } from "@/lib/auth-client"
|
|||
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
type NavRoleRequirement = "staff" | "admin" | "customer"
|
||||
type NavRoleRequirement = "staff" | "admin"
|
||||
|
||||
type NavigationItem = {
|
||||
title: string
|
||||
|
|
@ -91,6 +91,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
|||
},
|
||||
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
||||
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
|
||||
{ title: "Empresas & clientes", url: "/admin/companies", icon: Users, requiredRole: "admin" },
|
||||
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
|
||||
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
|
||||
],
|
||||
|
|
@ -105,7 +106,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
|||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const pathname = usePathname()
|
||||
const { session, isLoading, isAdmin, isStaff, isCustomer } = useAuth()
|
||||
const { session, isLoading, isAdmin, isStaff } = useAuth()
|
||||
const [isHydrated, setIsHydrated] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -128,7 +129,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
if (!requiredRole) return true
|
||||
if (requiredRole === "admin") return isAdmin
|
||||
if (requiredRole === "staff") return isStaff
|
||||
if (requiredRole === "customer") return isCustomer
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,14 +9,15 @@ import type { Id } from "@/convex/_generated/dataModel"
|
|||
import { useAuth } from "@/lib/auth-client"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
|
|
@ -111,19 +112,27 @@ export function ChartAreaInteractive() {
|
|||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Entrada de tickets por canal</CardTitle>
|
||||
<CardDescription>
|
||||
<CardTitle>Entrada de tickets por canal</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="hidden @[540px]/card:block">
|
||||
Distribuição dos canais nos últimos {timeRange.replace("d", " dias")}
|
||||
</span>
|
||||
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
<CardAction>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a
|
||||
href={`/api/reports/tickets-by-channel.csv?range=${timeRange}`}
|
||||
download
|
||||
>
|
||||
Exportar CSV
|
||||
</a>
|
||||
</Button>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const navItems = [
|
|||
export function PortalShell({ children }: PortalShellProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { session, isCustomer } = useAuth()
|
||||
const { session } = useAuth()
|
||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||
|
||||
const initials = useMemo(() => {
|
||||
|
|
@ -107,11 +107,7 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
</div>
|
||||
</header>
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-8">
|
||||
{!isCustomer ? (
|
||||
<div className="rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Este portal é voltado a clientes. Algumas ações podem não estar disponíveis para o seu perfil.
|
||||
</div>
|
||||
) : null}
|
||||
{null}
|
||||
{children}
|
||||
</main>
|
||||
<footer className="border-t border-slate-200 bg-white/70">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ const statusLabel: Record<Ticket["status"], string> = {
|
|||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
}
|
||||
|
||||
const statusTone: Record<Ticket["status"], string> = {
|
||||
|
|
@ -24,7 +23,6 @@ const statusTone: Record<Ticket["status"], string> = {
|
|||
AWAITING_ATTENDANCE: "bg-sky-100 text-sky-700",
|
||||
PAUSED: "bg-violet-100 text-violet-700",
|
||||
RESOLVED: "bg-emerald-100 text-emerald-700",
|
||||
CLOSED: "bg-slate-100 text-slate-600",
|
||||
}
|
||||
|
||||
const priorityLabel: Record<Ticket["priority"], string> = {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ const statusLabel: Record<TicketWithDetails["status"], string> = {
|
|||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
}
|
||||
|
||||
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { IconInbox, IconAlertTriangle, IconFilter } from "@tabler/icons-react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
|
|
@ -23,15 +25,15 @@ const STATUS_LABELS: Record<string, string> = {
|
|||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausados",
|
||||
RESOLVED: "Resolvidos",
|
||||
CLOSED: "Encerrados",
|
||||
}
|
||||
|
||||
export function BacklogReport() {
|
||||
const [timeRange, setTimeRange] = useState("90d")
|
||||
const { session, convexUserId } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const data = useQuery(
|
||||
api.reports.backlogOverview,
|
||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip"
|
||||
)
|
||||
|
||||
const mostCriticalPriority = useMemo(() => {
|
||||
|
|
@ -99,6 +101,24 @@ export function BacklogReport() {
|
|||
<CardDescription className="text-neutral-600">
|
||||
Acompanhe a evolução dos tickets pelas fases do fluxo de atendimento.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={`/api/reports/backlog.csv?range=${timeRange}`} download>
|
||||
Exportar CSV
|
||||
</a>
|
||||
</Button>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ const statusStyles: Record<TicketStatus, { label: string; className: string }> =
|
|||
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
|
||||
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
|
||||
CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
|
||||
}
|
||||
|
||||
type TicketStatusBadgeProps = { status: TicketStatus }
|
||||
|
|
|
|||
|
|
@ -14,14 +14,13 @@ import { ChevronDown } from "lucide-react"
|
|||
|
||||
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
|
||||
|
||||
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED", "CLOSED"];
|
||||
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"];
|
||||
|
||||
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
|
||||
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||
PAUSED: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
||||
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
|
||||
CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" },
|
||||
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||
OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconClock, IconFileDownload, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||
import { IconClock, IconFileTypePdf, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -24,6 +24,12 @@ import { Textarea } from "@/components/ui/textarea"
|
|||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
interface TicketHeaderProps {
|
||||
ticket: TicketWithDetails
|
||||
|
|
@ -128,6 +134,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
return selectedCategoryId !== currentCategoryId || selectedSubcategoryId !== currentSubcategoryId
|
||||
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
|
||||
const currentQueueName = ticket.queue ?? ""
|
||||
const isAvulso = Boolean((ticket as any).company?.isAvulso ?? false)
|
||||
const [queueSelection, setQueueSelection] = useState(currentQueueName)
|
||||
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
|
||||
const formDirty = dirty || categoryDirty || queueDirty
|
||||
|
|
@ -263,11 +270,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
return {
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
totalWorkedMs: ticket.workSummary.totalWorkedMs,
|
||||
internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0,
|
||||
externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0,
|
||||
activeSession: ticket.workSummary.activeSession
|
||||
? {
|
||||
id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">,
|
||||
agentId: ticket.workSummary.activeSession.agentId as Id<"users">,
|
||||
startedAt: ticket.workSummary.activeSession.startedAt.getTime(),
|
||||
workType: (ticket.workSummary.activeSession as any).workType ?? "INTERNAL",
|
||||
}
|
||||
: null,
|
||||
}
|
||||
|
|
@ -294,6 +304,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
|
||||
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
|
||||
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
|
||||
const internalWorkedMs = workSummary
|
||||
? (((workSummary as any).internalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "INTERNAL") ? currentSessionMs : 0))
|
||||
: 0
|
||||
const externalWorkedMs = workSummary
|
||||
? (((workSummary as any).externalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "EXTERNAL") ? currentSessionMs : 0))
|
||||
: 0
|
||||
|
||||
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])
|
||||
const updatedRelative = useMemo(
|
||||
|
|
@ -301,12 +317,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
[ticket.updatedAt]
|
||||
)
|
||||
|
||||
const handleStartWork = async () => {
|
||||
const handleStartWork = async (workType: "INTERNAL" | "EXTERNAL") => {
|
||||
if (!convexUserId) return
|
||||
toast.dismiss("work")
|
||||
toast.loading("Iniciando atendimento...", { id: "work" })
|
||||
try {
|
||||
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
|
||||
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users">, workType } as any)
|
||||
if (result?.status === "already_started") {
|
||||
toast.info("O atendimento já estava em andamento", { id: "work" })
|
||||
} else {
|
||||
|
|
@ -347,7 +363,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
setExportingPdf(true)
|
||||
toast.dismiss("ticket-export")
|
||||
toast.loading("Gerando PDF...", { id: "ticket-export" })
|
||||
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`)
|
||||
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`, { credentials: "include" })
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed: ${response.status}`)
|
||||
}
|
||||
|
|
@ -373,9 +389,17 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<div className={cardClass}>
|
||||
<div className="absolute right-6 top-6 flex items-center gap-3">
|
||||
{workSummary ? (
|
||||
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
|
||||
<IconClock className="size-4 text-neutral-700" /> Tempo total: {formattedTotalWorked}
|
||||
</Badge>
|
||||
<>
|
||||
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
|
||||
<IconClock className="size-4 text-neutral-700" /> Interno: {formatDuration(internalWorkedMs)}
|
||||
</Badge>
|
||||
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
|
||||
<IconClock className="size-4 text-neutral-700" /> Externo: {formatDuration(externalWorkedMs)}
|
||||
</Badge>
|
||||
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
|
||||
<IconClock className="size-4 text-neutral-700" /> Total: {formattedTotalWorked}
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
{!editing ? (
|
||||
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
|
||||
|
|
@ -383,45 +407,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-800 hover:bg-slate-50"
|
||||
aria-label="Exportar PDF"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-800 hover:bg-slate-50"
|
||||
onClick={handleExportPdf}
|
||||
disabled={exportingPdf}
|
||||
title="Exportar PDF"
|
||||
>
|
||||
{exportingPdf ? <Spinner className="size-3 text-neutral-700" /> : <IconFileDownload className="size-4" />}
|
||||
Exportar PDF
|
||||
{exportingPdf ? <Spinner className="size-4 text-neutral-700" /> : <IconFileTypePdf className="size-5" />}
|
||||
</Button>
|
||||
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
|
||||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||
<StatusSelect ticketId={ticket.id} value={status} />
|
||||
<Button
|
||||
size="sm"
|
||||
className={isPlaying ? pauseButtonClass : startButtonClass}
|
||||
onClick={() => {
|
||||
if (!convexUserId) return
|
||||
if (isPlaying) {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
|
||||
{isAvulso ? (
|
||||
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 text-sm font-semibold text-rose-700">
|
||||
Cliente avulso
|
||||
</Badge>
|
||||
) : null}
|
||||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||
<StatusSelect ticketId={ticket.id} value={status} />
|
||||
{isPlaying ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className={pauseButtonClass}
|
||||
onClick={() => {
|
||||
if (!convexUserId) return
|
||||
setPauseDialogOpen(true)
|
||||
} else {
|
||||
void handleStartWork()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<IconPlayerPause className="size-4 text-white" /> Pausar
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconPlayerPlay className="size-4 text-white" /> Iniciar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}}
|
||||
>
|
||||
<IconPlayerPause className="size-4 text-white" /> Pausar
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className={startButtonClass}>
|
||||
<IconPlayerPlay className="size-4 text-white" /> Iniciar
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
<DropdownMenuItem onSelect={() => void handleStartWork("INTERNAL")}>Iniciar (interno)</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void handleStartWork("EXTERNAL")}>Iniciar (externo)</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ const statusOptions: Array<{ value: TicketStatus; label: string }> = [
|
|||
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
|
||||
{ value: "PAUSED", label: "Pausado" },
|
||||
{ value: "RESOLVED", label: "Resolvido" },
|
||||
{ value: "CLOSED", label: "Fechado" },
|
||||
]
|
||||
|
||||
const statusLabelMap = statusOptions.reduce<Record<TicketStatus, string>>((acc, option) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { format, formatDistanceToNow, formatDistanceToNowStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { type LucideIcon, Code, FileText, Mail, MessageCircle, MessageSquare, Phone } from "lucide-react"
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ const channelIcon: Record<TicketChannel, LucideIcon> = {
|
|||
MANUAL: FileText,
|
||||
}
|
||||
|
||||
const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:pr-8"
|
||||
const cellClass = "px-4 py-4 align-middle text-sm text-neutral-700 whitespace-normal first:pl-5 last:pr-6"
|
||||
const channelIconBadgeClass = "inline-flex size-8 items-center justify-center rounded-full border border-slate-200 bg-slate-50 text-neutral-700"
|
||||
const categoryChipClass = "inline-flex items-center gap-1 rounded-full bg-slate-200/60 px-2.5 py-1 text-[11px] font-medium text-neutral-700"
|
||||
const tableRowClass =
|
||||
|
|
@ -53,7 +53,6 @@ const statusLabel: Record<TicketStatus, string> = {
|
|||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
}
|
||||
|
||||
const statusTone: Record<TicketStatus, string> = {
|
||||
|
|
@ -61,7 +60,6 @@ const statusTone: Record<TicketStatus, string> = {
|
|||
AWAITING_ATTENDANCE: "text-sky-700",
|
||||
PAUSED: "text-violet-700",
|
||||
RESOLVED: "text-emerald-700",
|
||||
CLOSED: "text-slate-600",
|
||||
}
|
||||
|
||||
function formatDuration(ms?: number) {
|
||||
|
|
@ -135,34 +133,34 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
return (
|
||||
<Card className="gap-0 rounded-3xl border border-slate-200 bg-white py-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table className="min-w-full overflow-hidden rounded-3xl">
|
||||
<Table className="min-w-full overflow-hidden rounded-3xl table-fixed">
|
||||
<TableHeader className="bg-slate-100/80">
|
||||
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
|
||||
<TableHead className="w-[120px] px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||
<TableHead className="w-[120px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||
Ticket
|
||||
</TableHead>
|
||||
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||
<TableHead className="w-[40%] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||
Assunto
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
|
||||
<TableHead className="hidden w-[120px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||
Fila
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
|
||||
<TableHead className="hidden w-[80px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
|
||||
Canal
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
|
||||
<TableHead className="hidden w-[100px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
|
||||
Prioridade
|
||||
</TableHead>
|
||||
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||
<TableHead className="w-[230px] pl-14 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
|
||||
<TableHead className="hidden w-[110px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||
Tempo
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 xl:table-cell">
|
||||
<TableHead className="hidden w-[200px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 xl:table-cell">
|
||||
Responsável
|
||||
</TableHead>
|
||||
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||
<TableHead className="w-[140px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||
Atualizado
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -196,11 +194,11 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="line-clamp-1 text-[15px] font-semibold text-neutral-900">
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<span className="text-[15px] font-semibold text-neutral-900 line-clamp-2 md:line-clamp-1 break-words">
|
||||
{ticket.subject}
|
||||
</span>
|
||||
<span className="line-clamp-1 text-sm text-neutral-600">
|
||||
<span className="text-sm text-neutral-600 line-clamp-1 break-words max-w-[52ch]">
|
||||
{ticket.summary ?? "Sem resumo"}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 text-xs text-neutral-500">
|
||||
|
|
@ -216,12 +214,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden lg:table-cell`}>
|
||||
<TableCell className={`${cellClass} hidden lg:table-cell pl-0`}>
|
||||
<span className="text-sm font-semibold text-neutral-800">
|
||||
{ticket.queue ?? "Sem fila"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell pl-1`}>
|
||||
<div className="flex items-center">
|
||||
<span className="sr-only">Canal {channelLabel[ticket.channel]}</span>
|
||||
<span
|
||||
|
|
@ -233,7 +231,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-8`}>
|
||||
<div
|
||||
className="inline-flex"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
|
|
@ -242,9 +240,9 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<TableCell className={`${cellClass} pl-14`}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={cn("text-sm font-semibold", statusTone[ticket.status])}>
|
||||
<span className={cn("text-sm font-semibold break-words leading-tight max-w-[140px] sm:max-w-[180px]", statusTone[ticket.status])}>
|
||||
{statusLabel[ticket.status]}
|
||||
</span>
|
||||
{ticket.metrics?.timeWaitingMinutes ? (
|
||||
|
|
@ -266,9 +264,14 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
<AssigneeCell ticket={ticket} />
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<span className="text-sm text-neutral-600">
|
||||
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-sm text-neutral-700">
|
||||
{`há cerca de ${formatDistanceToNowStrict(ticket.updatedAt, { locale: ptBR })}`}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function TicketsView() {
|
|||
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
||||
|
||||
const filteredTickets = useMemo(() => {
|
||||
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED", "CLOSED"])
|
||||
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED"])
|
||||
let working = tickets
|
||||
|
||||
if (!filters.status) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue