fix(reports): remove truncation cap in range collectors to avoid dropped records
feat(calendar): migrate to react-day-picker v9 and polish UI - Update classNames and CSS import (style.css) - Custom Dropdown via shadcn Select - Nav arrows aligned with caption (around) - Today highlight with cyan tone, weekdays in sentence case - Wider layout to avoid overflow; remove inner wrapper chore(tickets): make 'Patrimônio do computador (se houver)' optional - Backend hotfix to enforce optional + label on existing tenants - Hide required asterisk for this field in portal/new-ticket refactor(new-ticket): remove channel dropdown from admin/agent flow - Keep default channel as MANUAL feat(ux): simplify requester section and enlarge combobox trigger - Remove RequesterPreview redundancy; show company badge in trigger
This commit is contained in:
parent
e0ef66555d
commit
a8333c010f
28 changed files with 1752 additions and 455 deletions
|
|
@ -11,6 +11,7 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
|
|||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
export function AdminAlertsManager() {
|
||||
const [companyId, setCompanyId] = useState<string>("all")
|
||||
|
|
@ -47,6 +48,21 @@ export function AdminAlertsManager() {
|
|||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
|
||||
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) {
|
||||
return base
|
||||
}
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
|
|
@ -56,17 +72,13 @@ export function AdminAlertsManager() {
|
|||
</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:gap-2">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-full min-w-56 sm:w-64">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 sm:w-64"
|
||||
/>
|
||||
<Select value={range} onValueChange={setRange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Período" />
|
||||
|
|
|
|||
|
|
@ -935,6 +935,13 @@ const DEVICE_TYPE_LABELS: Record<string, string> = {
|
|||
tablet: "Tablet",
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_FILTER_OPTIONS = [
|
||||
{ value: "all", label: "Todos os tipos" },
|
||||
{ value: "desktop", label: DEVICE_TYPE_LABELS.desktop },
|
||||
{ value: "mobile", label: DEVICE_TYPE_LABELS.mobile },
|
||||
{ value: "tablet", label: DEVICE_TYPE_LABELS.tablet },
|
||||
]
|
||||
|
||||
function formatDeviceTypeLabel(value?: string | null): string {
|
||||
if (!value) return "Desconhecido"
|
||||
const normalized = value.toLowerCase()
|
||||
|
|
@ -1257,6 +1264,7 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
|
|||
const { devices, isLoading } = useDevicesQuery(tenantId)
|
||||
const [q, setQ] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
const [deviceTypeFilter, setDeviceTypeFilter] = useState<string>("all")
|
||||
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>(initialCompanyFilterSlug)
|
||||
const [companySearch, setCompanySearch] = useState<string>("")
|
||||
const [isCompanyPopoverOpen, setIsCompanyPopoverOpen] = useState(false)
|
||||
|
|
@ -1595,6 +1603,10 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
|
|||
const s = resolveDeviceStatus(m).toLowerCase()
|
||||
if (s !== statusFilter) return false
|
||||
}
|
||||
if (deviceTypeFilter !== "all") {
|
||||
const type = (m.deviceType ?? "desktop").toLowerCase()
|
||||
if (type !== deviceTypeFilter) return false
|
||||
}
|
||||
if (companyFilterSlug !== "all" && (m.companySlug ?? "") !== companyFilterSlug) return false
|
||||
if (!text) return true
|
||||
const hay = [
|
||||
|
|
@ -1607,7 +1619,7 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
|
|||
.toLowerCase()
|
||||
return hay.includes(text)
|
||||
})
|
||||
}, [devices, q, statusFilter, companyFilterSlug, onlyAlerts])
|
||||
}, [devices, q, statusFilter, companyFilterSlug, onlyAlerts, deviceTypeFilter])
|
||||
const handleOpenExportDialog = useCallback(() => {
|
||||
if (filteredDevices.length === 0) {
|
||||
toast.info("Não há dispositivos para exportar com os filtros atuais.")
|
||||
|
|
@ -1771,10 +1783,7 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
|
|||
<CardDescription>Sincronizadas via agente local ou Fleet. Atualiza em tempo real.</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleOpenCreateDevice}
|
||||
>
|
||||
<Button size="sm" onClick={handleOpenCreateDevice}>
|
||||
<Plus className="size-4" />
|
||||
Novo dispositivo
|
||||
</Button>
|
||||
|
|
@ -1797,6 +1806,18 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
|
|||
<SelectItem value="unknown">Desconhecido</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={deviceTypeFilter} onValueChange={setDeviceTypeFilter}>
|
||||
<SelectTrigger className="min-w-40">
|
||||
<SelectValue placeholder="Tipo de dispositivo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEVICE_TYPE_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover open={isCompanyPopoverOpen} onOpenChange={setIsCompanyPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="min-w-56 justify-between">
|
||||
|
|
@ -2392,12 +2413,12 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
|
||||
const alertsHistory = useQuery(
|
||||
api.devices.listAlerts,
|
||||
device ? { deviceId: device.id as Id<"machines">, limit: 50 } : "skip"
|
||||
device ? { machineId: device.id as Id<"machines">, limit: 50 } : "skip"
|
||||
) as DeviceAlertEntry[] | undefined
|
||||
const deviceAlertsHistory = alertsHistory ?? []
|
||||
const openTickets = useQuery(
|
||||
api.devices.listOpenTickets,
|
||||
device ? { deviceId: device.id as Id<"machines">, limit: 6 } : "skip"
|
||||
device ? { machineId: device.id as Id<"machines">, limit: 6 } : "skip"
|
||||
) as DeviceOpenTicketsSummary | undefined
|
||||
const deviceTickets = openTickets?.tickets ?? []
|
||||
const totalOpenTickets = openTickets?.totalOpen ?? deviceTickets.length
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
export const description = "Distribuição semanal de tickets por canal"
|
||||
|
||||
|
|
@ -48,7 +48,6 @@ export function ChartAreaInteractive() {
|
|||
const [timeRange, setTimeRange] = React.useState("7d")
|
||||
// Persistir seleção de empresa globalmente
|
||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||
const [companyQuery, setCompanyQuery] = React.useState("")
|
||||
const { session, convexUserId, isStaff } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
|
|
@ -73,11 +72,20 @@ export function ChartAreaInteractive() {
|
|||
api.companies.list,
|
||||
reportsEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
const filteredCompanies = React.useMemo(() => {
|
||||
const q = companyQuery.trim().toLowerCase()
|
||||
if (!q) return companies ?? []
|
||||
return (companies ?? []).filter((c) => c.name.toLowerCase().includes(q))
|
||||
}, [companies, companyQuery])
|
||||
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) {
|
||||
return base
|
||||
}
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
|
||||
const channels = React.useMemo(() => report?.channels ?? [], [report])
|
||||
|
||||
|
|
@ -138,25 +146,13 @@ export function ChartAreaInteractive() {
|
|||
<CardAction>
|
||||
<div className="flex w-full flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end sm:gap-2">
|
||||
{/* Company picker with search */}
|
||||
<Select value={companyId} onValueChange={(v) => { setCompanyId(v); }}>
|
||||
<SelectTrigger className="w-full min-w-56 sm:w-64">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="Pesquisar empresa..."
|
||||
value={companyQuery}
|
||||
onChange={(e) => setCompanyQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{filteredCompanies.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 sm:w-64"
|
||||
/>
|
||||
|
||||
{/* Desktop time range toggles */}
|
||||
<ToggleGroup
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ import {
|
|||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
type PriorityKey = "LOW" | "MEDIUM" | "HIGH" | "URGENT"
|
||||
|
||||
|
|
@ -63,6 +63,21 @@ export function ChartOpenByPriority() {
|
|||
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
|
||||
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) {
|
||||
return base
|
||||
}
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
|
||||
if (!report) {
|
||||
return <Skeleton className="h-[300px] w-full" />
|
||||
}
|
||||
|
|
@ -88,19 +103,13 @@ export function ChartOpenByPriority() {
|
|||
<CardDescription>Distribuição de tickets iniciados e ainda abertos</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-48 md:w-48"
|
||||
/>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import { useAuth } from "@/lib/auth-client"
|
|||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { formatDateDM, formatDateDMY } from "@/lib/utils"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
type SeriesPoint = { date: string; opened: number; resolved: number }
|
||||
|
||||
|
|
@ -47,6 +47,21 @@ export function ChartOpenedResolved() {
|
|||
reportsEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
|
||||
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) {
|
||||
return base
|
||||
}
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
|
||||
if (!data) {
|
||||
return <Skeleton className="h-[300px] w-full" />
|
||||
}
|
||||
|
|
@ -60,17 +75,13 @@ export function ChartOpenedResolved() {
|
|||
</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 md:w-56"
|
||||
/>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction }
|
|||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
export function ViewsCharts() {
|
||||
return (
|
||||
|
|
@ -39,6 +40,21 @@ function BacklogPriorityPie() {
|
|||
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
|
||||
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) {
|
||||
return base
|
||||
}
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
|
||||
if (!data) return <Skeleton className="h-[300px] w-full" />
|
||||
const PRIORITY_LABELS: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Crítica" }
|
||||
const keys = ["LOW", "MEDIUM", "HIGH", "URGENT"]
|
||||
|
|
@ -60,17 +76,13 @@ function BacklogPriorityPie() {
|
|||
<CardDescription>Distribuição de tickets no período</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 md:w-56"
|
||||
/>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Período" />
|
||||
|
|
@ -121,6 +133,20 @@ function QueuesOpenBar() {
|
|||
api.companies.list,
|
||||
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) {
|
||||
return base
|
||||
}
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
|
||||
if (!data) return <Skeleton className="h-[300px] w-full" />
|
||||
const chartData = (data.queueBreakdown ?? []).map((q) => ({ queue: q.name, open: q.open }))
|
||||
|
|
@ -132,17 +158,13 @@ function QueuesOpenBar() {
|
|||
<CardTitle>Filas com maior volume aberto</CardTitle>
|
||||
<CardDescription>Distribuição atual por fila</CardDescription>
|
||||
<CardAction>
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 md:w-56"
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
|
|||
|
|
@ -53,16 +53,16 @@ export function PortalTicketCard({ ticket }: PortalTicketCardProps) {
|
|||
<p className="mt-1 line-clamp-2 text-sm text-neutral-600">{ticket.summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-right">
|
||||
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold uppercase", getTicketStatusBadgeClass(ticket.status))}>
|
||||
{getTicketStatusLabel(ticket.status)}
|
||||
<div className="flex flex-col items-end gap-2 text-right">
|
||||
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", getTicketStatusBadgeClass(ticket.status))}>
|
||||
{getTicketStatusLabel(ticket.status)}
|
||||
</Badge>
|
||||
{!isCustomer ? (
|
||||
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityTone[ticket.priority])}>
|
||||
{priorityLabel[ticket.priority]}
|
||||
</Badge>
|
||||
{!isCustomer ? (
|
||||
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold uppercase", priorityTone[ticket.priority])}>
|
||||
{priorityLabel[ticket.priority]}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-100 px-5 py-4 text-sm text-neutral-600">
|
||||
{!isCustomer ? (
|
||||
|
|
|
|||
|
|
@ -296,9 +296,9 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-sm">
|
||||
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold uppercase" />
|
||||
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold" />
|
||||
{!isCustomer ? (
|
||||
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}>
|
||||
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold ${priorityTone[ticket.priority]}`}>
|
||||
{priorityLabel[ticket.priority]}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
|
@ -693,4 +693,3 @@ function PortalCommentAttachmentCard({
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { format, parseISO } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMutation } from "convex/react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
@ -16,6 +18,13 @@ import { Button } from "@/components/ui/button"
|
|||
import { CategorySelectFields } from "@/components/tickets/category-select"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { normalizeCustomFieldInputs, hasMissingRequiredCustomFields } from "@/lib/ticket-form-helpers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TicketFormDefinition } from "@/lib/ticket-form-types"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
|
||||
const DEFAULT_PRIORITY: TicketPriority = "MEDIUM"
|
||||
|
||||
|
|
@ -34,10 +43,79 @@ export function PortalTicketForm() {
|
|||
const { convexUserId, session, machineContext, machineContextError, machineContextLoading } = useAuth()
|
||||
const createTicket = useMutation(api.tickets.create)
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const ensureTicketFormDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
|
||||
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
|
||||
|
||||
const formsRemote = useQuery(
|
||||
api.tickets.listTicketForms,
|
||||
viewerId ? { tenantId, viewerId } : "skip"
|
||||
) as TicketFormDefinition[] | undefined
|
||||
|
||||
const forms = useMemo<TicketFormDefinition[]>(() => {
|
||||
const base: TicketFormDefinition = {
|
||||
key: "default",
|
||||
label: "Chamado padrão",
|
||||
description: "Formulário básico para solicitações gerais.",
|
||||
fields: [],
|
||||
}
|
||||
if (Array.isArray(formsRemote) && formsRemote.length > 0) {
|
||||
return [base, ...formsRemote]
|
||||
}
|
||||
return [base]
|
||||
}, [formsRemote])
|
||||
|
||||
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
||||
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
|
||||
const hasEnsuredFormsRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerId || hasEnsuredFormsRef.current) return
|
||||
hasEnsuredFormsRef.current = true
|
||||
ensureTicketFormDefaults({
|
||||
tenantId,
|
||||
actorId: viewerId,
|
||||
}).catch((error) => {
|
||||
console.error("Falha ao preparar formulários personalizados", error)
|
||||
hasEnsuredFormsRef.current = false
|
||||
})
|
||||
}, [viewerId, tenantId, ensureTicketFormDefaults])
|
||||
|
||||
useEffect(() => {
|
||||
if (!forms.length) return
|
||||
if (!forms.find((form) => form.key === selectedFormKey)) {
|
||||
setSelectedFormKey(forms[0].key)
|
||||
setCustomFieldValues({})
|
||||
}
|
||||
}, [forms, selectedFormKey])
|
||||
|
||||
const selectedForm = useMemo(
|
||||
() => forms.find((form) => form.key === selectedFormKey) ?? forms[0],
|
||||
[forms, selectedFormKey]
|
||||
)
|
||||
|
||||
const customFieldsInvalid = useMemo(
|
||||
() =>
|
||||
selectedFormKey !== "default" && selectedForm?.fields?.length
|
||||
? hasMissingRequiredCustomFields(selectedForm.fields, customFieldValues)
|
||||
: false,
|
||||
[selectedFormKey, selectedForm, customFieldValues]
|
||||
)
|
||||
|
||||
const handleFormSelection = (value: string) => {
|
||||
setSelectedFormKey(value)
|
||||
setCustomFieldValues({})
|
||||
}
|
||||
|
||||
const handleCustomFieldChange = (fieldId: string, value: unknown) => {
|
||||
setCustomFieldValues((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const [subject, setSubject] = useState("")
|
||||
const [summary, setSummary] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
|
|
@ -48,11 +126,18 @@ export function PortalTicketForm() {
|
|||
() => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0),
|
||||
[attachments]
|
||||
)
|
||||
const allowTicketMentions = true
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const machineInactive = machineContext?.isActive === false
|
||||
const isFormValid = useMemo(() => {
|
||||
return Boolean(subject.trim() && description.trim() && categoryId && subcategoryId && !machineInactive)
|
||||
}, [subject, description, categoryId, subcategoryId, machineInactive])
|
||||
if (!subject.trim() || !description.trim() || !categoryId || !subcategoryId || machineInactive) {
|
||||
return false
|
||||
}
|
||||
if (customFieldsInvalid) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [subject, description, categoryId, subcategoryId, machineInactive, customFieldsInvalid])
|
||||
const isViewerReady = Boolean(viewerId)
|
||||
const viewerErrorMessage = useMemo(() => {
|
||||
if (!machineContextError) return null
|
||||
|
|
@ -85,6 +170,16 @@ export function PortalTicketForm() {
|
|||
return
|
||||
}
|
||||
|
||||
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
||||
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
|
||||
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
|
||||
if (!normalized.ok) {
|
||||
toast.error(normalized.message, { id: "portal-new-ticket" })
|
||||
return
|
||||
}
|
||||
customFieldsPayload = normalized.payload
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
toast.loading("Abrindo chamado...", { id: "portal-new-ticket" })
|
||||
try {
|
||||
|
|
@ -93,14 +188,16 @@ export function PortalTicketForm() {
|
|||
tenantId,
|
||||
subject: trimmedSubject,
|
||||
summary: trimmedSummary || undefined,
|
||||
priority: DEFAULT_PRIORITY,
|
||||
channel: "MANUAL",
|
||||
queueId: undefined,
|
||||
requesterId: viewerId,
|
||||
categoryId: categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
|
||||
machineId: machineContext?.machineId ? (machineContext.machineId as Id<"machines">) : undefined,
|
||||
})
|
||||
priority: DEFAULT_PRIORITY,
|
||||
channel: "MANUAL",
|
||||
queueId: undefined,
|
||||
requesterId: viewerId,
|
||||
categoryId: categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
|
||||
machineId: machineContext?.machineId ? (machineContext.machineId as Id<"machines">) : undefined,
|
||||
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
|
||||
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
|
||||
})
|
||||
|
||||
if (plainDescription.length > 0) {
|
||||
const MAX_COMMENT_CHARS = 20000
|
||||
|
|
@ -128,6 +225,8 @@ export function PortalTicketForm() {
|
|||
|
||||
toast.success("Chamado criado com sucesso!", { id: "portal-new-ticket" })
|
||||
setAttachments([])
|
||||
setCustomFieldValues({})
|
||||
setSelectedFormKey("default")
|
||||
router.replace(`/portal/tickets/${id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
|
@ -162,6 +261,27 @@ export function PortalTicketForm() {
|
|||
</div>
|
||||
) : null}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{forms.length > 1 ? (
|
||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||
<p className="text-sm font-semibold text-neutral-800">Tipo de solicitação</p>
|
||||
<Select value={selectedFormKey} onValueChange={handleFormSelection} disabled={isSubmitting || machineInactive}>
|
||||
<SelectTrigger className="h-9 rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0">
|
||||
<SelectValue placeholder="Selecione uma opção" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
{forms.map((formOption) => (
|
||||
<SelectItem key={formOption.key} value={formOption.key} className="text-sm">
|
||||
{formOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedForm?.description ? (
|
||||
<p className="text-xs text-neutral-500">{selectedForm.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="subject" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
|
||||
|
|
@ -216,6 +336,179 @@ export function PortalTicketForm() {
|
|||
subcategoryLabel="Subcategoria *"
|
||||
secondaryEmptyLabel="Selecione uma categoria"
|
||||
/>
|
||||
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
|
||||
<div className="grid gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2">
|
||||
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
|
||||
{selectedForm.fields.map((field) => {
|
||||
const value = customFieldValues[field.id]
|
||||
const fieldId = `portal-custom-field-${field.id}`
|
||||
const isRequiredStar = field.required && field.key !== "colaborador_patrimonio"
|
||||
const labelSuffix = isRequiredStar ? <span className="text-red-500">*</span> : null
|
||||
const helpText = field.description ? (
|
||||
<p className="text-xs text-neutral-500">{field.description}</p>
|
||||
) : null
|
||||
const spanClass =
|
||||
field.type === "boolean" || field.key.includes("observacao") || field.key.includes("permissao")
|
||||
? "sm:col-span-2"
|
||||
: ""
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<label
|
||||
key={field.id}
|
||||
htmlFor={fieldId}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-800",
|
||||
spanClass || "sm:col-span-2"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:ring-[#00d6eb]/40"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => handleCustomFieldChange(field.id, event.target.checked)}
|
||||
disabled={isSubmitting || machineInactive}
|
||||
/>
|
||||
<span>
|
||||
{field.label} {labelSuffix}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<div key={field.id} className={cn("space-y-1", spanClass)}>
|
||||
<span className="flex items-center gap-1 text-sm font-medium text-neutral-800">
|
||||
{field.label} {labelSuffix}
|
||||
</span>
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(selected) => handleCustomFieldChange(field.id, selected)}
|
||||
disabled={isSubmitting || machineInactive}
|
||||
>
|
||||
<SelectTrigger className="h-9 rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-sm">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{helpText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<div key={field.id} className={cn("space-y-1", spanClass)}>
|
||||
<label htmlFor={fieldId} className="flex items-center gap-1 text-sm font-medium text-neutral-800">
|
||||
{field.label} {labelSuffix}
|
||||
</label>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field.id, event.target.value)}
|
||||
disabled={isSubmitting || machineInactive}
|
||||
/>
|
||||
{helpText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
const parsedDate =
|
||||
typeof value === "string" && value ? parseISO(value) : undefined
|
||||
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
|
||||
return (
|
||||
<div key={field.id} className={cn("space-y-1", spanClass)}>
|
||||
<label htmlFor={fieldId} className="flex items-center gap-1 text-sm font-medium text-neutral-800">
|
||||
{field.label} {labelSuffix}
|
||||
</label>
|
||||
<Popover
|
||||
open={openCalendarField === field.id}
|
||||
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isSubmitting || machineInactive}
|
||||
className={cn(
|
||||
"w-full justify-between gap-2 text-left font-normal",
|
||||
!isValidDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{isValidDate
|
||||
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
|
||||
: "Selecionar data"}
|
||||
</span>
|
||||
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={isValidDate ? (parsedDate as Date) : undefined}
|
||||
onSelect={(selected) => {
|
||||
handleCustomFieldChange(field.id, selected ? format(selected, "yyyy-MM-dd") : "")
|
||||
setOpenCalendarField(null)
|
||||
}}
|
||||
initialFocus
|
||||
captionLayout="dropdown"
|
||||
startMonth={new Date(1900, 0)}
|
||||
endMonth={new Date(new Date().getFullYear() + 5, 11)}
|
||||
locale={ptBR}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{helpText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const shouldUseTextarea = field.key.includes("observacao") || field.key.includes("permissao")
|
||||
|
||||
return (
|
||||
<div key={field.id} className={cn("space-y-1", spanClass)}>
|
||||
<label htmlFor={fieldId} className="flex items-center gap-1 text-sm font-medium text-neutral-800">
|
||||
{field.label} {labelSuffix}
|
||||
</label>
|
||||
{shouldUseTextarea ? (
|
||||
<textarea
|
||||
id={fieldId}
|
||||
className="min-h-[90px] rounded-lg border border-slate-300 px-3 py-2 text-sm text-neutral-800 shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-900/10 disabled:cursor-not-allowed"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field.id, event.target.value)}
|
||||
disabled={isSubmitting || machineInactive}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field.id, event.target.value)}
|
||||
disabled={isSubmitting || machineInactive}
|
||||
/>
|
||||
)}
|
||||
{helpText}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{customFieldsInvalid ? (
|
||||
<p className="text-xs font-semibold text-red-500">
|
||||
Preencha todos os campos obrigatórios antes de registrar o chamado.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-neutral-800">Anexos (opcional)</span>
|
||||
<Dropzone
|
||||
|
|
@ -254,4 +547,3 @@ export function PortalTicketForm() {
|
|||
</Card>
|
||||
)
|
||||
}
|
||||
const allowTicketMentions = true
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ 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"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||
import { Pie, PieChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
LOW: "Baixa",
|
||||
|
|
@ -52,6 +52,20 @@ export function BacklogReport() {
|
|||
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
|
||||
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) {
|
||||
return base
|
||||
}
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
const mostCriticalPriority = useMemo(() => {
|
||||
if (!data) return null
|
||||
const entries = Object.entries(data.priorityCounts) as Array<[string, number]>
|
||||
|
|
@ -119,17 +133,13 @@ export function BacklogReport() {
|
|||
</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="hidden w-56 md:flex">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 md:w-56"
|
||||
/>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ import { Badge } from "@/components/ui/badge"
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import type { ChartConfig } from "@/components/ui/chart"
|
||||
import { formatHoursCompact } from "@/lib/utils"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
type HoursItem = {
|
||||
companyId: string
|
||||
|
|
@ -58,6 +58,20 @@ export function HoursReport() {
|
|||
api.companies.list,
|
||||
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) {
|
||||
return base
|
||||
}
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
const filtered = useMemo(() => {
|
||||
const items = data?.items ?? []
|
||||
const q = query.trim().toLowerCase()
|
||||
|
|
@ -178,17 +192,13 @@ export function HoursReport() {
|
|||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-9 w-full min-w-56 sm:w-72"
|
||||
/>
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-full min-w-56 sm:w-64">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 sm:w-64"
|
||||
/>
|
||||
<ToggleGroup type="single" value={timeRange} onValueChange={setTimeRange} variant="outline" className="hidden md:flex">
|
||||
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
|
|||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useState } from "react"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { formatDateDM, formatDateDMY, formatHoursCompact } from "@/lib/utils"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
const agentProductivityChartConfig = {
|
||||
resolved: {
|
||||
|
|
@ -71,6 +71,21 @@ export function SlaReport() {
|
|||
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||
|
||||
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||
if (!companies || companies.length === 0) {
|
||||
return base
|
||||
}
|
||||
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||
return [
|
||||
base[0],
|
||||
...sorted.map((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
})),
|
||||
]
|
||||
}, [companies])
|
||||
|
||||
const queueTotal = useMemo(
|
||||
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
|
||||
[data]
|
||||
|
|
@ -142,17 +157,13 @@ export function SlaReport() {
|
|||
</div>
|
||||
<CardAction>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableCombobox
|
||||
value={companyId}
|
||||
onValueChange={(next) => setCompanyId(next ?? "all")}
|
||||
options={companyOptions}
|
||||
placeholder="Todas as empresas"
|
||||
className="w-full min-w-56 sm:w-56"
|
||||
/>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
@ -14,9 +14,18 @@ import { Label } from "@/components/ui/label"
|
|||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import type { TicketStatus } from "@/lib/schemas/ticket"
|
||||
|
||||
type ClosingTemplate = { id: string; title: string; body: string }
|
||||
|
||||
type TicketLinkSuggestion = {
|
||||
id: string
|
||||
reference: number
|
||||
subject: string
|
||||
status: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
const DEFAULT_PHONE_NUMBER = "(11) 4173-5368"
|
||||
const DEFAULT_COMPANY_NAME = "Rever Tecnologia"
|
||||
|
||||
|
|
@ -100,6 +109,21 @@ const formatDurationLabel = (ms: number) => {
|
|||
return `${minutes}min`
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<TicketStatus, string> = {
|
||||
PENDING: "Pendente",
|
||||
AWAITING_ATTENDANCE: "Em atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
}
|
||||
|
||||
function formatTicketStatusLabel(status: string) {
|
||||
const normalized = (status ?? "").toUpperCase()
|
||||
if (normalized in STATUS_LABELS) {
|
||||
return STATUS_LABELS[normalized as TicketStatus]
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
export function CloseTicketDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -164,23 +188,35 @@ export function CloseTicketDialog({
|
|||
const [adjustReason, setAdjustReason] = useState<string>("")
|
||||
const enableAdjustment = Boolean(canAdjustTime && workSummary)
|
||||
const [linkedReference, setLinkedReference] = useState<string>("")
|
||||
const [linkSuggestions, setLinkSuggestions] = useState<TicketLinkSuggestion[]>([])
|
||||
const [linkedTicketSelection, setLinkedTicketSelection] = useState<TicketLinkSuggestion | null>(null)
|
||||
const [showLinkSuggestions, setShowLinkSuggestions] = useState(false)
|
||||
const [isSearchingLinks, setIsSearchingLinks] = useState(false)
|
||||
const linkSuggestionsAbortRef = useRef<AbortController | null>(null)
|
||||
const linkedReferenceInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const suggestionHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [reopenWindowDays, setReopenWindowDays] = useState<string>("14")
|
||||
|
||||
const digitsOnlyReference = linkedReference.replace(/[^0-9]/g, "").trim()
|
||||
|
||||
const normalizedReference = useMemo(() => {
|
||||
const digits = linkedReference.replace(/[^0-9]/g, "").trim()
|
||||
if (!digits) return null
|
||||
const parsed = Number(digits)
|
||||
if (!digitsOnlyReference) return null
|
||||
const parsed = Number(digitsOnlyReference)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
||||
if (ticketReference && parsed === ticketReference) return null
|
||||
return parsed
|
||||
}, [linkedReference, ticketReference])
|
||||
}, [digitsOnlyReference, ticketReference])
|
||||
|
||||
const linkedTicket = useQuery(
|
||||
api.tickets.findByReference,
|
||||
actorId && normalizedReference ? { tenantId, viewerId: actorId, reference: normalizedReference } : "skip"
|
||||
) as { id: Id<"tickets">; reference: number; subject: string; status: string } | null | undefined
|
||||
const isLinkLoading = Boolean(actorId && normalizedReference && linkedTicket === undefined)
|
||||
const linkNotFound = Boolean(normalizedReference && linkedTicket === null && !isLinkLoading)
|
||||
const hasSufficientDigits = digitsOnlyReference.length >= 3
|
||||
const linkedTicketCandidate = linkedTicketSelection ?? (linkedTicket ?? null)
|
||||
const linkNotFound = Boolean(
|
||||
hasSufficientDigits && !isLinkLoading && !linkedTicketSelection && linkedTicket === null && linkSuggestions.length === 0
|
||||
)
|
||||
|
||||
const hydrateTemplateBody = useCallback((templateHtml: string) => {
|
||||
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
|
||||
|
|
@ -198,8 +234,21 @@ export function CloseTicketDialog({
|
|||
setInternalMinutes("0")
|
||||
setExternalHours("0")
|
||||
setExternalMinutes("0")
|
||||
setLinkedReference("")
|
||||
setLinkedTicketSelection(null)
|
||||
setLinkSuggestions([])
|
||||
setShowLinkSuggestions(false)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
linkSuggestionsAbortRef.current?.abort()
|
||||
if (suggestionHideTimeoutRef.current) {
|
||||
clearTimeout(suggestionHideTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (templates.length > 0 && !selectedTemplateId && !message) {
|
||||
|
|
@ -232,11 +281,138 @@ export function CloseTicketDialog({
|
|||
}
|
||||
}, [shouldAdjustTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const rawQuery = linkedReference.trim()
|
||||
if (rawQuery.length < 2) {
|
||||
linkSuggestionsAbortRef.current?.abort()
|
||||
setIsSearchingLinks(false)
|
||||
setLinkSuggestions([])
|
||||
setShowLinkSuggestions(false)
|
||||
return
|
||||
}
|
||||
|
||||
const digitsForSelection = String(linkedTicketSelection?.reference ?? "")
|
||||
if (linkedTicketSelection && digitsForSelection === digitsOnlyReference) {
|
||||
setLinkSuggestions([])
|
||||
setIsSearchingLinks(false)
|
||||
return
|
||||
}
|
||||
|
||||
linkSuggestionsAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
linkSuggestionsAbortRef.current = controller
|
||||
setIsSearchingLinks(true)
|
||||
if (linkedReferenceInputRef.current && document.activeElement === linkedReferenceInputRef.current) {
|
||||
setShowLinkSuggestions(true)
|
||||
}
|
||||
|
||||
fetch(`/api/tickets/mentions?q=${encodeURIComponent(rawQuery)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
return { items: [] as TicketLinkSuggestion[] }
|
||||
}
|
||||
const json = (await response.json()) as { items?: Array<{ id: string; reference: number; subject?: string | null; status?: string | null; priority?: string | null }> }
|
||||
const items = Array.isArray(json.items) ? json.items : []
|
||||
return {
|
||||
items: items
|
||||
.filter((item) => String(item.id) !== ticketId && Number(item.reference) !== ticketReference)
|
||||
.map((item) => ({
|
||||
id: String(item.id),
|
||||
reference: Number(item.reference),
|
||||
subject: item.subject ?? "",
|
||||
status: item.status ?? "PENDING",
|
||||
priority: item.priority ?? "MEDIUM",
|
||||
})),
|
||||
}
|
||||
})
|
||||
.then(({ items }) => {
|
||||
if (controller.signal.aborted) {
|
||||
return
|
||||
}
|
||||
setLinkSuggestions(items)
|
||||
if (linkedReferenceInputRef.current && document.activeElement === linkedReferenceInputRef.current) {
|
||||
setShowLinkSuggestions(true)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
console.error("Falha ao buscar tickets para vincular", error)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsSearchingLinks(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [open, linkedReference, digitsOnlyReference, linkedTicketSelection, ticketId, ticketReference])
|
||||
|
||||
const handleTemplateSelect = (template: ClosingTemplate) => {
|
||||
setSelectedTemplateId(template.id)
|
||||
setMessage(hydrateTemplateBody(template.body))
|
||||
}
|
||||
|
||||
const handleLinkedReferenceChange = (value: string) => {
|
||||
setLinkedReference(value)
|
||||
const digits = value.replace(/[^0-9]/g, "").trim()
|
||||
if (value.trim().length === 0) {
|
||||
setLinkedTicketSelection(null)
|
||||
setLinkSuggestions([])
|
||||
setShowLinkSuggestions(false)
|
||||
return
|
||||
}
|
||||
if (linkedTicketSelection && String(linkedTicketSelection.reference) !== digits) {
|
||||
setLinkedTicketSelection(null)
|
||||
}
|
||||
if (value.trim().length >= 2) {
|
||||
setShowLinkSuggestions(true)
|
||||
} else {
|
||||
setShowLinkSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLinkedReferenceFocus = () => {
|
||||
if (suggestionHideTimeoutRef.current) {
|
||||
clearTimeout(suggestionHideTimeoutRef.current)
|
||||
suggestionHideTimeoutRef.current = null
|
||||
}
|
||||
if (linkSuggestions.length > 0) {
|
||||
setShowLinkSuggestions(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLinkedReferenceBlur = () => {
|
||||
suggestionHideTimeoutRef.current = setTimeout(() => {
|
||||
setShowLinkSuggestions(false)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const handleSelectLinkSuggestion = (suggestion: TicketLinkSuggestion) => {
|
||||
if (suggestionHideTimeoutRef.current) {
|
||||
clearTimeout(suggestionHideTimeoutRef.current)
|
||||
suggestionHideTimeoutRef.current = null
|
||||
}
|
||||
setLinkedTicketSelection(suggestion)
|
||||
setLinkedReference(`#${suggestion.reference}`)
|
||||
setShowLinkSuggestions(false)
|
||||
setLinkSuggestions([])
|
||||
}
|
||||
|
||||
const handleLinkedReferenceKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
if (linkSuggestions.length > 0) {
|
||||
event.preventDefault()
|
||||
handleSelectLinkSuggestion(linkSuggestions[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!actorId) {
|
||||
toast.error("É necessário estar autenticado para encerrar o ticket.")
|
||||
|
|
@ -296,7 +472,7 @@ export function CloseTicketDialog({
|
|||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
if (linkNotFound || !linkedTicket) {
|
||||
if (linkNotFound || !linkedTicketCandidate) {
|
||||
toast.error("Não encontramos o ticket informado para vincular. Verifique o número e tente novamente.", {
|
||||
id: "close-ticket",
|
||||
})
|
||||
|
|
@ -320,7 +496,7 @@ export function CloseTicketDialog({
|
|||
await resolveTicketMutation({
|
||||
ticketId: ticketId as unknown as Id<"tickets">,
|
||||
actorId,
|
||||
resolvedWithTicketId: linkedTicket ? (linkedTicket.id as Id<"tickets">) : undefined,
|
||||
resolvedWithTicketId: linkedTicketCandidate ? (linkedTicketCandidate.id as Id<"tickets">) : undefined,
|
||||
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
|
||||
})
|
||||
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
|
||||
|
|
@ -399,13 +575,51 @@ export function CloseTicketDialog({
|
|||
<Label htmlFor="linked-reference" className="text-sm font-medium text-neutral-800">
|
||||
Ticket relacionado (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
id="linked-reference"
|
||||
value={linkedReference}
|
||||
onChange={(event) => setLinkedReference(event.target.value)}
|
||||
placeholder="Número do ticket relacionado (ex.: 12345)"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="linked-reference"
|
||||
ref={linkedReferenceInputRef}
|
||||
value={linkedReference}
|
||||
onChange={(event) => handleLinkedReferenceChange(event.target.value)}
|
||||
onFocus={handleLinkedReferenceFocus}
|
||||
onBlur={handleLinkedReferenceBlur}
|
||||
onKeyDown={handleLinkedReferenceKeyDown}
|
||||
placeholder="Buscar por número ou assunto do ticket"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{showLinkSuggestions ? (
|
||||
<div className="absolute left-0 right-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-lg border border-slate-200 bg-white shadow-lg">
|
||||
{isSearchingLinks ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-sm text-neutral-500">
|
||||
<Spinner className="size-3" /> Buscando tickets relacionados...
|
||||
</div>
|
||||
) : linkSuggestions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-neutral-500">Nenhum ticket encontrado.</div>
|
||||
) : (
|
||||
linkSuggestions.map((suggestion) => {
|
||||
const statusLabel = formatTicketStatusLabel(suggestion.status)
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => handleSelectLinkSuggestion(suggestion)}
|
||||
className="flex w-full flex-col gap-1 px-3 py-2 text-left text-sm transition hover:bg-slate-100 focus:bg-slate-100"
|
||||
>
|
||||
<span className="font-semibold text-neutral-900">
|
||||
#{suggestion.reference}
|
||||
</span>
|
||||
{suggestion.subject ? (
|
||||
<span className="text-xs text-neutral-600">{suggestion.subject}</span>
|
||||
) : null}
|
||||
<span className="text-xs text-neutral-500">Status: {statusLabel}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{linkedReference.trim().length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">Informe o número de outro ticket quando o atendimento estiver relacionado.</p>
|
||||
) : isLinkLoading ? (
|
||||
|
|
@ -414,9 +628,9 @@ export function CloseTicketDialog({
|
|||
</p>
|
||||
) : linkNotFound ? (
|
||||
<p className="text-xs text-red-500">Ticket não encontrado ou sem acesso permitido. Verifique o número informado.</p>
|
||||
) : linkedTicket ? (
|
||||
) : linkedTicketCandidate ? (
|
||||
<p className="text-xs text-emerald-600">
|
||||
Será registrado vínculo com o ticket #{linkedTicket.reference} — {linkedTicket.subject ?? "Sem assunto"}
|
||||
Será registrado vínculo com o ticket #{linkedTicketCandidate.reference} — {linkedTicketCandidate.subject ?? "Sem assunto"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { format, parseISO } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import type { Doc, Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
|
|
@ -24,8 +26,13 @@ import { CategorySelectFields } from "@/components/tickets/category-select"
|
|||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
|
||||
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
|
||||
type CustomerOption = {
|
||||
id: string
|
||||
|
|
@ -90,23 +97,6 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
|
|||
|
||||
const NO_COMPANY_VALUE = "__no_company__"
|
||||
|
||||
type TicketFormFieldDefinition = {
|
||||
id: string
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
description: string
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
type TicketFormDefinition = {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
fields: TicketFormFieldDefinition[]
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
subject: z.string().default(""),
|
||||
summary: z.string().optional(),
|
||||
|
|
@ -175,6 +165,21 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
[companiesRemote]
|
||||
)
|
||||
|
||||
const ensureTicketFormDefaultsMutation = useMutation(api.tickets.ensureTicketFormDefaults)
|
||||
const hasEnsuredFormsRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!convexUserId || hasEnsuredFormsRef.current) return
|
||||
hasEnsuredFormsRef.current = true
|
||||
ensureTicketFormDefaultsMutation({
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
}).catch((error) => {
|
||||
console.error("Falha ao preparar formulários personalizados", error)
|
||||
hasEnsuredFormsRef.current = false
|
||||
})
|
||||
}, [convexUserId, ensureTicketFormDefaultsMutation])
|
||||
|
||||
const formsRemote = useQuery(
|
||||
api.tickets.listTicketForms,
|
||||
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
|
|
@ -195,6 +200,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
|
||||
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
||||
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
|
||||
|
||||
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
|
||||
|
||||
|
|
@ -225,7 +231,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
[attachments]
|
||||
)
|
||||
const priorityValue = form.watch("priority") as TicketPriority
|
||||
const channelValue = form.watch("channel")
|
||||
const queueValue = form.watch("queueName") ?? "NONE"
|
||||
const assigneeValue = form.watch("assigneeId") ?? null
|
||||
const assigneeSelectValue = assigneeValue ?? "NONE"
|
||||
|
|
@ -386,6 +391,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
useEffect(() => {
|
||||
if (!open) {
|
||||
setAssigneeInitialized(false)
|
||||
setOpenCalendarField(null)
|
||||
return
|
||||
}
|
||||
if (assigneeInitialized) return
|
||||
|
|
@ -449,49 +455,13 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
|
||||
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
||||
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
|
||||
for (const field of selectedForm.fields) {
|
||||
const raw = customFieldValues[field.id]
|
||||
const isBooleanField = field.type === "boolean"
|
||||
const isEmpty =
|
||||
raw === undefined ||
|
||||
raw === null ||
|
||||
(typeof raw === "string" && raw.trim().length === 0)
|
||||
|
||||
if (isBooleanField) {
|
||||
const boolValue = Boolean(raw)
|
||||
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value: boolValue })
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.required && isEmpty) {
|
||||
toast.error(`Preencha o campo "${field.label}".`, { id: "new-ticket" })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
continue
|
||||
}
|
||||
|
||||
let value: unknown = raw
|
||||
if (field.type === "number") {
|
||||
const parsed = typeof raw === "number" ? raw : Number(raw)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
toast.error(`Informe um valor numérico válido para "${field.label}".`, { id: "new-ticket" })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
value = parsed
|
||||
} else if (field.type === "boolean") {
|
||||
value = Boolean(raw)
|
||||
} else if (field.type === "date") {
|
||||
value = String(raw)
|
||||
} else {
|
||||
value = String(raw)
|
||||
}
|
||||
|
||||
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value })
|
||||
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
|
||||
if (!normalized.ok) {
|
||||
toast.error(normalized.message, { id: "new-ticket" })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
customFieldsPayload = normalized.payload
|
||||
}
|
||||
setLoading(true)
|
||||
toast.loading("Criando ticket…", { id: "new-ticket" })
|
||||
|
|
@ -601,7 +571,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
</div>
|
||||
{forms.length > 1 ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||
<p className="text-sm font-semibold text-neutral-800">Modelo de ticket</p>
|
||||
<p className="text-sm font-semibold text-neutral-800">Tipo de solicitação</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{forms.map((formDef) => (
|
||||
<Button
|
||||
|
|
@ -744,7 +714,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
<FieldLabel className="flex items-center gap-1">
|
||||
Solicitante <span className="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<RequesterPreview customer={selectedRequester} company={selectedCompanyOption} />
|
||||
<SearchableCombobox
|
||||
value={requesterValue || null}
|
||||
onValueChange={(nextValue) => {
|
||||
|
|
@ -778,18 +747,27 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
placeholder={filteredCustomers.length === 0 ? "Nenhum usuário disponível" : "Selecionar solicitante"}
|
||||
searchPlaceholder="Buscar por nome ou e-mail..."
|
||||
disabled={filteredCustomers.length === 0}
|
||||
renderValue={(option) =>
|
||||
option ? (
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate font-medium text-foreground">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
|
||||
renderValue={(option) => {
|
||||
if (!option) return <span className="text-muted-foreground">Selecionar solicitante</span>
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium text-foreground">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedCompanyOption && selectedCompanyOption.id !== NO_COMPANY_VALUE ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="hidden shrink-0 rounded-full px-2.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground sm:inline-flex"
|
||||
>
|
||||
{selectedCompanyOption.name}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Selecionar solicitante</span>
|
||||
)
|
||||
}
|
||||
}}
|
||||
renderOption={(option) => {
|
||||
const record = requesterById.get(option.value)
|
||||
const initials = getInitials(record?.name, record?.email ?? option.label)
|
||||
|
|
@ -862,34 +840,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Canal</FieldLabel>
|
||||
<Select value={channelValue} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Canal" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
<SelectItem value="EMAIL" className={selectItemClass}>
|
||||
E-mail
|
||||
</SelectItem>
|
||||
<SelectItem value="WHATSAPP" className={selectItemClass}>
|
||||
WhatsApp
|
||||
</SelectItem>
|
||||
<SelectItem value="CHAT" className={selectItemClass}>
|
||||
Chat
|
||||
</SelectItem>
|
||||
<SelectItem value="PHONE" className={selectItemClass}>
|
||||
Telefone
|
||||
</SelectItem>
|
||||
<SelectItem value="API" className={selectItemClass}>
|
||||
API
|
||||
</SelectItem>
|
||||
<SelectItem value="MANUAL" className={selectItemClass}>
|
||||
Manual
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{/* Canal removido da UI: padrão MANUAL será enviado */}
|
||||
<Field>
|
||||
<FieldLabel>Fila</FieldLabel>
|
||||
<Select value={queueValue} onValueChange={(v) => form.setValue("queueName", v === "NONE" ? null : v)}>
|
||||
|
|
@ -935,119 +886,180 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
|
||||
<div className="space-y-4 rounded-xl border border-slate-200 bg-white px-4 py-4">
|
||||
<p className="text-sm font-semibold text-neutral-800">Informações adicionais</p>
|
||||
{selectedForm.fields.map((field) => {
|
||||
const value = customFieldValues[field.id]
|
||||
const fieldId = `custom-field-${field.id}`
|
||||
const labelSuffix = field.required ? <span className="text-destructive">*</span> : null
|
||||
const helpText = field.description ? (
|
||||
<p className="text-xs text-neutral-500">{field.description}</p>
|
||||
) : null
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
|
||||
{field.label} {labelSuffix}
|
||||
</label>
|
||||
{helpText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
|
||||
>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="date"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
|
||||
<div className="grid gap-4 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2 lg:col-span-2">
|
||||
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
|
||||
{selectedForm.fields.map((field) => {
|
||||
const value = customFieldValues[field.id]
|
||||
const fieldId = `custom-field-${field.id}`
|
||||
const isRequiredStar = field.required && field.key !== "colaborador_patrimonio"
|
||||
const labelSuffix = isRequiredStar ? <span className="text-destructive">*</span> : null
|
||||
const helpText = field.description ? (
|
||||
<p className="text-xs text-neutral-500">{field.description}</p>
|
||||
) : null
|
||||
const shouldUseTextarea = field.key.includes("observacao") || field.key.includes("permissao")
|
||||
const spanClass = shouldUseTextarea || field.type === "boolean" ? "sm:col-span-2" : ""
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
|
||||
spanClass,
|
||||
"sm:col-span-2"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
|
||||
{field.label} {labelSuffix}
|
||||
</label>
|
||||
{helpText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
|
||||
>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
const parsedDate =
|
||||
typeof value === "string" && value ? parseISO(value) : undefined
|
||||
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Popover
|
||||
open={openCalendarField === field.id}
|
||||
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between gap-2 text-left font-normal",
|
||||
!isValidDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{isValidDate
|
||||
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
|
||||
: "Selecionar data"}
|
||||
</span>
|
||||
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={isValidDate ? (parsedDate as Date) : undefined}
|
||||
onSelect={(selected) => {
|
||||
handleCustomFieldChange(
|
||||
field,
|
||||
selected ? format(selected, "yyyy-MM-dd") : ""
|
||||
)
|
||||
setOpenCalendarField(null)
|
||||
}}
|
||||
initialFocus
|
||||
captionLayout="dropdown"
|
||||
startMonth={new Date(1900, 0)}
|
||||
endMonth={new Date(new Date().getFullYear() + 5, 11)}
|
||||
locale={ptBR}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldUseTextarea) {
|
||||
return (
|
||||
<Field key={field.id} className={cn("flex-col", spanClass, "sm:col-span-2")}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<textarea
|
||||
id={fieldId}
|
||||
className="min-h-[90px] rounded-lg border border-slate-300 px-3 py-2 text-sm text-neutral-800 shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-900/10"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -32,10 +32,44 @@ function formatRelative(timestamp: Date | null | undefined) {
|
|||
|
||||
export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
|
||||
const router = useRouter()
|
||||
const { session, convexUserId, role: authRole } = useAuth()
|
||||
const { session, convexUserId, role: authRole, machineContext } = useAuth()
|
||||
const submitCsat = useMutation(api.tickets.submitCsat)
|
||||
|
||||
const viewerRole = (authRole ?? session?.user.role ?? "").toUpperCase()
|
||||
const deriveViewerRole = () => {
|
||||
const authRoleNormalized = authRole?.toLowerCase()?.trim()
|
||||
const machinePersona = machineContext?.persona ?? session?.user.machinePersona ?? null
|
||||
const assignedRole = machineContext?.assignedUserRole ?? null
|
||||
const sessionRole = session?.user.role?.toLowerCase()?.trim()
|
||||
|
||||
if (authRoleNormalized && authRoleNormalized !== "machine") {
|
||||
return authRoleNormalized.toUpperCase()
|
||||
}
|
||||
|
||||
if (authRoleNormalized === "machine" && machinePersona) {
|
||||
return machinePersona.toUpperCase()
|
||||
}
|
||||
|
||||
if (machinePersona) {
|
||||
return machinePersona.toUpperCase()
|
||||
}
|
||||
|
||||
if (assignedRole) {
|
||||
return assignedRole.toUpperCase()
|
||||
}
|
||||
|
||||
if (sessionRole && sessionRole !== "machine") {
|
||||
return sessionRole.toUpperCase()
|
||||
}
|
||||
|
||||
if (sessionRole === "machine") {
|
||||
return "COLLABORATOR"
|
||||
}
|
||||
|
||||
return "COLLABORATOR"
|
||||
}
|
||||
|
||||
const viewerRole = deriveViewerRole()
|
||||
|
||||
const viewerEmail = session?.user.email?.trim().toLowerCase() ?? ""
|
||||
const viewerId = convexUserId as Id<"users"> | undefined
|
||||
|
||||
|
|
@ -187,6 +221,10 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
|
|||
</span>
|
||||
{ratedAtRelative ? ` • ${ratedAtRelative}` : null}
|
||||
</p>
|
||||
) : viewerIsStaff ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-neutral-600">
|
||||
Nenhuma avaliação registrada ainda.
|
||||
</div>
|
||||
) : null}
|
||||
{canSubmit ? (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
195
src/components/ui/calendar.tsx
Normal file
195
src/components/ui/calendar.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import type { DropdownProps } from "react-day-picker"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
import "react-day-picker/style.css"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
type OptionElement = React.ReactElement<{
|
||||
value: string | number
|
||||
children: React.ReactNode
|
||||
disabled?: boolean
|
||||
}>
|
||||
|
||||
type CalendarDropdownProps = DropdownProps & { children?: React.ReactNode }
|
||||
|
||||
const buildOptions = (options?: DropdownProps["options"], children?: React.ReactNode) => {
|
||||
if (options && options.length > 0) {
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
disabled: option.disabled,
|
||||
}))
|
||||
}
|
||||
|
||||
return React.Children.toArray(children)
|
||||
.filter((child): child is OptionElement => React.isValidElement(child))
|
||||
.map((child) => ({
|
||||
value: child.props.value,
|
||||
label: child.props.children,
|
||||
disabled: child.props.disabled ?? false,
|
||||
}))
|
||||
}
|
||||
|
||||
function CalendarDropdown(props: CalendarDropdownProps) {
|
||||
const { value, onChange, options: optionProp, children } = props
|
||||
const disabled = props.disabled
|
||||
const ariaLabel = props["aria-label"]
|
||||
|
||||
const options = React.useMemo(() => buildOptions(optionProp, children), [optionProp, children])
|
||||
const stringValue = value !== undefined && value !== null ? String(value) : ""
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
const match = options.find((option) => String(option.value) === next)
|
||||
const payload = match ? match.value : next
|
||||
onChange?.({
|
||||
target: { value: payload },
|
||||
} as React.ChangeEvent<HTMLSelectElement>)
|
||||
}
|
||||
|
||||
const isYearDropdown = options.every((option) => String(option.value).length === 4)
|
||||
const triggerWidth = isYearDropdown ? "w-[96px]" : "w-[108px]"
|
||||
|
||||
const displayText = React.useMemo(() => {
|
||||
if (!options.length) return ""
|
||||
const selected = options.find((o) => String(o.value) === stringValue)
|
||||
if (!selected) return ""
|
||||
if (isYearDropdown) return String(selected.label)
|
||||
const label = String(selected.label)
|
||||
const abbr = label.slice(0, 3)
|
||||
return abbr.charAt(0).toUpperCase() + abbr.slice(1).toLowerCase()
|
||||
}, [options, stringValue, isYearDropdown])
|
||||
|
||||
return (
|
||||
<Select value={stringValue} onValueChange={handleChange} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"h-8 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700 shadow-none transition hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-neutral-900/10 disabled:cursor-not-allowed disabled:opacity-60",
|
||||
triggerWidth
|
||||
)}
|
||||
>
|
||||
{/* Mostra mês abreviado no trigger; lista usa label completo */}
|
||||
<span className="truncate">
|
||||
{displayText}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
className="max-h-72 min-w-[var(--radix-select-trigger-width)] rounded-2xl border border-[#00e8ff]/20 bg-white/95 text-neutral-800 shadow-[0_10px_40px_-10px_rgba(0,155,177,0.25)] backdrop-blur-sm"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={String(option.value)}
|
||||
value={String(option.value)}
|
||||
disabled={option.disabled}
|
||||
className="rounded-lg text-sm font-medium text-neutral-700 focus:bg-slate-100 focus:text-neutral-900"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "dropdown",
|
||||
startMonth,
|
||||
endMonth,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
captionLayout={captionLayout}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
className={cn(
|
||||
"mx-auto flex w-[320px] flex-col gap-3 rounded-2xl bg-transparent p-4 text-neutral-900 shadow-none",
|
||||
className
|
||||
)}
|
||||
classNames={{
|
||||
months: "flex flex-col items-center gap-4",
|
||||
// Layout em grid de 3 colunas para posicionar setas à esquerda/direita
|
||||
month: "grid grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-3",
|
||||
month_caption: "col-start-2 col-end-3 flex items-center justify-center gap-2 px-0",
|
||||
dropdowns: "flex items-center gap-2",
|
||||
nav: "flex items-center gap-1",
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon" }),
|
||||
"col-start-1 row-start-1 size-8 rounded-full border border-transparent text-neutral-500 hover:border-slate-200 hover:bg-slate-50 hover:text-neutral-900 focus-visible:ring-2 focus-visible:ring-neutral-900/10"
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon" }),
|
||||
"col-start-3 row-start-1 size-8 rounded-full border border-transparent text-neutral-500 hover:border-slate-200 hover:bg-slate-50 hover:text-neutral-900 focus-visible:ring-2 focus-visible:ring-neutral-900/10"
|
||||
),
|
||||
chevron: "size-4",
|
||||
month_grid: "col-span-3 w-full border-collapse",
|
||||
weekdays:
|
||||
"grid grid-cols-7 text-[0.7rem] font-medium capitalize text-muted-foreground",
|
||||
weekday: "flex h-8 items-center justify-center",
|
||||
week: "grid grid-cols-7 gap-1",
|
||||
day: "relative flex h-9 items-center justify-center text-sm focus-within:relative focus-within:z-20",
|
||||
day_button: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"size-9 rounded-full font-medium text-neutral-700 transition hover:bg-slate-100 hover:text-neutral-900 data-[selected]:rounded-full data-[selected]:bg-neutral-900 data-[selected]:text-neutral-50 data-[selected]:ring-0 data-[range-start]:rounded-s-full data-[range-end]:rounded-e-full"
|
||||
),
|
||||
// Hoje: estilos principais irão no botão via modifiersClassNames
|
||||
today: "text-neutral-900",
|
||||
outside: "text-muted-foreground opacity-40",
|
||||
disabled: "text-muted-foreground opacity-30 line-through",
|
||||
range_middle: "bg-neutral-900/10 text-neutral-900",
|
||||
hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
modifiersClassNames={{
|
||||
today:
|
||||
"[&_button]:bg-[#00e8ff]/20 [&_button]:text-neutral-900 [&_button]:ring-1 [&_button]:ring-[#00d6eb] [&_button]:ring-offset-1 [&_button]:ring-offset-white",
|
||||
}}
|
||||
// Setas ao lado do caption
|
||||
navLayout="around"
|
||||
components={{
|
||||
Dropdown: CalendarDropdown,
|
||||
// Tornar os botões de navegação estáticos para encaixar no grid
|
||||
PreviousMonthButton: (props) => {
|
||||
const { className, children, style, ...rest } = props
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{ ...(style || {}), position: "static" }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
NextMonthButton: (props) => {
|
||||
const { className, children, style, ...rest } = props
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{ ...(style || {}), position: "static" }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -93,7 +93,7 @@ export function SearchableCombobox({
|
|||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex min-h-[42px] w-full items-center justify-between rounded-full border border-input bg-background px-3 py-2 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"flex min-h-[46px] w-full items-center justify-between rounded-full border border-input bg-background px-3 py-2.5 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue