Reorganiza gestão de usuários e remove dados mock
This commit is contained in:
parent
630110bf3a
commit
dded6d1927
20 changed files with 1863 additions and 1368 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
|
||||
import { IconSearch, IconUserPlus } from "@tabler/icons-react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
|
@ -215,6 +216,50 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users])
|
||||
const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
|
||||
|
||||
const defaultCreateRole = (selectableRoles.find((role) => role !== "machine") ?? "agent") as RoleOption
|
||||
const [teamSearch, setTeamSearch] = useState("")
|
||||
const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all")
|
||||
const [machineSearch, setMachineSearch] = useState("")
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
role: defaultCreateRole,
|
||||
tenantId: defaultTenantId,
|
||||
companyId: NO_COMPANY_ID,
|
||||
})
|
||||
const [isCreatingUser, setIsCreatingUser] = useState(false)
|
||||
const [createPassword, setCreatePassword] = useState<string | null>(null)
|
||||
|
||||
const filteredTeamUsers = useMemo(() => {
|
||||
const term = teamSearch.trim().toLowerCase()
|
||||
return teamUsers.filter((user) => {
|
||||
if (teamRoleFilter !== "all" && coerceRole(user.role) !== teamRoleFilter) return false
|
||||
if (!term) return true
|
||||
const haystack = [
|
||||
user.name ?? "",
|
||||
user.email ?? "",
|
||||
user.companyName ?? "",
|
||||
formatRole(user.role),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
return haystack.includes(term)
|
||||
})
|
||||
}, [teamUsers, teamSearch, teamRoleFilter])
|
||||
|
||||
const filteredMachineUsers = useMemo(() => {
|
||||
const term = machineSearch.trim().toLowerCase()
|
||||
if (!term) return machineUsers
|
||||
return machineUsers.filter((user) => {
|
||||
return (
|
||||
(user.name ?? "").toLowerCase().includes(term) ||
|
||||
user.email.toLowerCase().includes(term) ||
|
||||
(user.machinePersona ?? "").toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
}, [machineUsers, machineSearch])
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
|
|
@ -248,6 +293,14 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
})
|
||||
setPasswordPreview(null)
|
||||
}, [editUser, defaultTenantId])
|
||||
useEffect(() => {
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
role: defaultCreateRole,
|
||||
tenantId: defaultTenantId,
|
||||
}))
|
||||
}, [defaultCreateRole, defaultTenantId])
|
||||
|
||||
|
||||
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
|
@ -405,6 +458,115 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
}
|
||||
}
|
||||
|
||||
const resetCreateForm = useCallback(() => {
|
||||
setCreateForm({
|
||||
name: "",
|
||||
email: "",
|
||||
role: defaultCreateRole,
|
||||
tenantId: defaultTenantId,
|
||||
companyId: NO_COMPANY_ID,
|
||||
})
|
||||
setCreatePassword(null)
|
||||
}, [defaultCreateRole, defaultTenantId])
|
||||
|
||||
const handleOpenCreateUser = useCallback(() => {
|
||||
resetCreateForm()
|
||||
setCreateDialogOpen(true)
|
||||
}, [resetCreateForm])
|
||||
|
||||
const handleCloseCreateDialog = useCallback(() => {
|
||||
resetCreateForm()
|
||||
setCreateDialogOpen(false)
|
||||
}, [resetCreateForm])
|
||||
|
||||
async function handleCreateUser(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const payload = {
|
||||
name: createForm.name.trim(),
|
||||
email: createForm.email.trim().toLowerCase(),
|
||||
role: createForm.role,
|
||||
tenantId: createForm.tenantId.trim() || defaultTenantId,
|
||||
}
|
||||
|
||||
if (!payload.name) {
|
||||
toast.error("Informe o nome do usuário")
|
||||
return
|
||||
}
|
||||
if (!payload.email || !payload.email.includes("@")) {
|
||||
toast.error("Informe um e-mail válido")
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreatingUser(true)
|
||||
setCreatePassword(null)
|
||||
try {
|
||||
const response = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Não foi possível criar o usuário")
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { user: { id: string; email: string; name: string | null; role: string; tenantId: string | null; createdAt: string }; temporaryPassword: string }
|
||||
const normalizedRole = coerceRole(data.user.role)
|
||||
const normalizedUser: AdminUser = {
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
name: data.user.name ?? data.user.email,
|
||||
role: normalizedRole,
|
||||
tenantId: data.user.tenantId ?? defaultTenantId,
|
||||
createdAt: data.user.createdAt,
|
||||
updatedAt: null,
|
||||
companyId: null,
|
||||
companyName: null,
|
||||
machinePersona: null,
|
||||
}
|
||||
|
||||
if (createForm.companyId && createForm.companyId !== NO_COMPANY_ID) {
|
||||
try {
|
||||
const assignResponse = await fetch("/api/admin/users/assign-company", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email: normalizedUser.email, companyId: createForm.companyId }),
|
||||
})
|
||||
if (assignResponse.ok) {
|
||||
const company = companies.find((company) => company.id === createForm.companyId)
|
||||
normalizedUser.companyId = createForm.companyId
|
||||
normalizedUser.companyName = company?.name ?? null
|
||||
} else {
|
||||
const assignData = await assignResponse.json().catch(() => ({}))
|
||||
toast.error(assignData.error ?? "Não foi possível vincular a empresa")
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Falha ao vincular empresa"
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
setUsers((previous) => [normalizedUser, ...previous])
|
||||
setCreatePassword(data.temporaryPassword)
|
||||
toast.success("Usuário criado com sucesso")
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
name: "",
|
||||
email: "",
|
||||
companyId: NO_COMPANY_ID,
|
||||
}))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao criar usuário"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsCreatingUser(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveUser(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!editUser) return
|
||||
|
|
@ -549,6 +711,56 @@ async function handleDeleteUser() {
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="users" className="mt-6 space-y-6">
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-neutral-900">Equipe cadastrada</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{filteredTeamUsers.length} {filteredTeamUsers.length === 1 ? "colaborador" : "colaboradores"}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreateUser} size="sm" className="gap-2 self-start sm:self-auto">
|
||||
<IconUserPlus className="size-4" />
|
||||
Novo usuário
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={teamSearch}
|
||||
onChange={(event) => setTeamSearch(event.target.value)}
|
||||
placeholder="Buscar por nome, e-mail ou empresa..."
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select value={teamRoleFilter} onValueChange={(value) => setTeamRoleFilter(value as "all" | RoleOption)}>
|
||||
<SelectTrigger className="h-9 w-full sm:w-48">
|
||||
<SelectValue placeholder="Todos os papéis" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os papéis</SelectItem>
|
||||
{selectableRoles.filter((option) => option !== "machine").map((option) => (
|
||||
<SelectItem key={`team-filter-${option}`} value={option}>
|
||||
{formatRole(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTeamSearch("")
|
||||
setTeamRoleFilter("all")
|
||||
}}
|
||||
>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Equipe cadastrada</CardTitle>
|
||||
|
|
@ -568,7 +780,7 @@ async function handleDeleteUser() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{teamUsers.map((user) => (
|
||||
{filteredTeamUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
|
|
@ -590,9 +802,9 @@ async function handleDeleteUser() {
|
|||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-500/10"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
|
|
@ -605,10 +817,12 @@ async function handleDeleteUser() {
|
|||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{teamUsers.length === 0 ? (
|
||||
{filteredTeamUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-6 text-center text-neutral-500">
|
||||
Nenhum usuário cadastrado até o momento.
|
||||
{teamUsers.length === 0
|
||||
? "Nenhum usuário cadastrado até o momento."
|
||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
|
|
@ -661,6 +875,25 @@ async function handleDeleteUser() {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="machines" className="mt-6 space-y-6">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-neutral-900">Agentes de máquina</p>
|
||||
<p className="text-xs text-neutral-500">{filteredMachineUsers.length} {filteredMachineUsers.length === 1 ? "agente" : "agentes"} vinculados via desktop client.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={machineSearch}
|
||||
onChange={(event) => setMachineSearch(event.target.value)}
|
||||
placeholder="Buscar por hostname ou e-mail técnico"
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agentes de máquina</CardTitle>
|
||||
|
|
@ -678,33 +911,41 @@ async function handleDeleteUser() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{machineUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/machines">Gerenciar</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-500/10"
|
||||
onClick={() => setDeleteUserId(user.id)}
|
||||
>
|
||||
Remover acesso
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{machineUsers.length === 0 ? (
|
||||
{filteredMachineUsers.map((user) => {
|
||||
const machineId = extractMachineId(user.email)
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditUserId(user.id)}>
|
||||
Editar
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={machineId ? `/admin/machines/${machineId}` : "/admin/machines"}>Detalhes da máquina</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
onClick={() => setDeleteUserId(user.id)}
|
||||
>
|
||||
Remover acesso
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{filteredMachineUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-neutral-500">
|
||||
Nenhuma máquina provisionada ainda.
|
||||
{machineUsers.length === 0
|
||||
? "Nenhuma máquina provisionada ainda."
|
||||
: "Nenhum agente encontrado para a busca atual."}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
|
|
@ -751,7 +992,7 @@ async function handleDeleteUser() {
|
|||
<div className="grid gap-2">
|
||||
<Label>Papel</Label>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectTrigger id="invite-role" className="h-9">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -888,6 +1129,119 @@ async function handleDeleteUser() {
|
|||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setCreateDialogOpen(true)
|
||||
} else {
|
||||
handleCloseCreateDialog()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Novo usuário</DialogTitle>
|
||||
<DialogDescription>Crie uma conta para membros da equipe com acesso imediato ao sistema.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreateUser} className="space-y-5">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="create-name">Nome</Label>
|
||||
<Input
|
||||
id="create-name"
|
||||
placeholder="Nome completo"
|
||||
value={createForm.name}
|
||||
onChange={(event) => setCreateForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="create-email">E-mail</Label>
|
||||
<Input
|
||||
id="create-email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
placeholder="nome@suaempresa.com"
|
||||
value={createForm.email}
|
||||
onChange={(event) => setCreateForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="create-role">Papel</Label>
|
||||
<Select
|
||||
value={createForm.role}
|
||||
onValueChange={(value) => setCreateForm((prev) => ({ ...prev, role: value as RoleOption }))}
|
||||
>
|
||||
<SelectTrigger id="create-role" className="h-9">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableRoles.filter((option) => option !== "machine").map((option) => (
|
||||
<SelectItem key={`create-role-${option}`} value={option}>
|
||||
{formatRole(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="create-tenant">Espaço (tenant)</Label>
|
||||
<Input
|
||||
id="create-tenant"
|
||||
placeholder="tenant-atlas"
|
||||
value={createForm.tenantId}
|
||||
onChange={(event) => setCreateForm((prev) => ({ ...prev, tenantId: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="create-company">Empresa vinculada</Label>
|
||||
<Select
|
||||
value={createForm.companyId}
|
||||
onValueChange={(value) => setCreateForm((prev) => ({ ...prev, companyId: value }))}
|
||||
>
|
||||
<SelectTrigger id="create-company" className="h-9">
|
||||
<SelectValue placeholder="Sem vínculo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companyOptions.map((company) => (
|
||||
<SelectItem key={`create-company-${company.id}`} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{createPassword ? (
|
||||
<div className="rounded-lg border border-slate-300 bg-slate-50 p-3">
|
||||
<p className="text-xs text-neutral-600">Senha temporária gerada:</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<code className="text-sm font-semibold text-neutral-900">{createPassword}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(createPassword).then(() => toast.success("Senha copiada"))}
|
||||
>
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={handleCloseCreateDialog} disabled={isCreatingUser}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreatingUser}>
|
||||
{isCreatingUser ? "Criando..." : "Criar usuário"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Sheet open={Boolean(editUser)} onOpenChange={(open) => (!open ? setEditUserId(null) : null)}>
|
||||
<SheetContent side="right" className="space-y-6 overflow-y-auto px-6 pb-10 sm:max-w-2xl">
|
||||
|
|
|
|||
|
|
@ -235,9 +235,9 @@ export function AdminClientsManager({ initialClients }: { initialClients: AdminC
|
|||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDelete([row.original.id])}
|
||||
>
|
||||
|
|
@ -313,9 +313,9 @@ export function AdminClientsManager({ initialClients }: { initialClients: AdminC
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
|
||||
disabled={selectedRows.length === 0 || isPending}
|
||||
onClick={() => handleDelete(selectedRows.map((row) => row.id))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -82,6 +82,18 @@ type MachineSummary = {
|
|||
architecture?: string | null
|
||||
}
|
||||
|
||||
function formatCnpjInput(value: string): string {
|
||||
const digits = value.replace(/\D/g, "").slice(0, 14)
|
||||
if (digits.length <= 2) return digits
|
||||
if (digits.length <= 5) return `${digits.slice(0, 2)}.${digits.slice(2)}`
|
||||
if (digits.length <= 8) return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5)}`
|
||||
if (digits.length <= 12) {
|
||||
return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8)}`
|
||||
}
|
||||
return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8, 12)}-${digits.slice(12)}`
|
||||
}
|
||||
|
||||
|
||||
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
|
||||
const [companies, setCompanies] = useState<Company[]>(() => initialCompanies ?? [])
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
|
@ -139,6 +151,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
setForm({
|
||||
...c,
|
||||
contractedHoursPerMonth: c.contractedHoursPerMonth ?? undefined,
|
||||
cnpj: c.cnpj ? formatCnpjInput(c.cnpj) : null,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -163,11 +176,13 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
? form.contractedHoursPerMonth
|
||||
: null
|
||||
|
||||
const cnpjDigits = form.cnpj ? form.cnpj.replace(/\D/g, "") : ""
|
||||
|
||||
const payload = {
|
||||
name: form.name?.trim(),
|
||||
slug: form.slug?.trim(),
|
||||
isAvulso: Boolean(form.isAvulso ?? false),
|
||||
cnpj: form.cnpj?.trim() || null,
|
||||
cnpj: cnpjDigits ? cnpjDigits : null,
|
||||
domain: form.domain?.trim() || null,
|
||||
phone: form.phone?.trim() || null,
|
||||
description: form.description?.trim() || null,
|
||||
|
|
@ -331,7 +346,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
name="companyName"
|
||||
value={form.name ?? ""}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="Nome da empresa ou apelido interno"
|
||||
placeholder="Nome da empresa"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -360,7 +375,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
id={cnpjId}
|
||||
name="companyCnpj"
|
||||
value={form.cnpj ?? ""}
|
||||
onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))}
|
||||
onChange={(e) => setForm((p) => ({ ...p, cnpj: formatCnpjInput(e.target.value) }))}
|
||||
placeholder="00.000.000/0000-00"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -472,7 +487,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
) : null}
|
||||
|
||||
<Card className="border-slate-200/80 shadow-none">
|
||||
<CardHeader className="gap-4 border-b border-slate-100 py-6 dark:border-slate-800/60">
|
||||
<CardHeader className="gap-4 bg-white px-6 py-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-semibold tracking-tight">Empresas cadastradas</CardTitle>
|
||||
|
|
@ -489,14 +504,14 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Buscar por nome, slug ou domínio..."
|
||||
className="pl-9"
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:self-start"
|
||||
className="gap-2 self-start sm:self-auto"
|
||||
disabled={isPending}
|
||||
onClick={() => startTransition(() => { void refresh() })}
|
||||
>
|
||||
|
|
@ -505,9 +520,9 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<CardContent className="bg-white p-4">
|
||||
{isMobile ? (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
{hasCompanies ? (
|
||||
filteredCompanies.map((company) => {
|
||||
const companyMachines = machinesByCompanyId.get(company.id) ?? []
|
||||
|
|
@ -554,6 +569,10 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
<IconPencil className="size-4" />
|
||||
Editar empresa
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setMachinesDialog({ companyId: company.id, name: company.name })}>
|
||||
<IconDeviceDesktop className="size-4" />
|
||||
Ver máquinas
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void toggleAvulso(company)}>
|
||||
<IconSwitchHorizontal className="size-4" />
|
||||
{company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"}
|
||||
|
|
@ -673,9 +692,9 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<Table className="min-w-full table-fixed text-sm">
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted/60 backdrop-blur supports-[backdrop-filter]:bg-muted/40">
|
||||
<TableHeader className="bg-slate-50">
|
||||
<TableRow className="border-b border-slate-200 dark:border-slate-800/60 [&_th]:h-10 [&_th]:text-xs [&_th]:font-medium [&_th]:uppercase [&_th]:tracking-wide [&_th]:text-muted-foreground [&_th:first-child]:rounded-tl-lg [&_th:last-child]:rounded-tr-lg">
|
||||
<TableHead className="w-[30%] min-w-[220px] pl-6">Empresa</TableHead>
|
||||
<TableHead className="w-[22%] min-w-[180px] pl-4">Provisionamento</TableHead>
|
||||
|
|
@ -935,23 +954,26 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir empresa</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="flex items-center gap-2 text-amber-600">
|
||||
<IconAlertTriangle className="size-5" />
|
||||
<DialogTitle className="text-base font-semibold text-neutral-900">Confirmar exclusão</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-sm text-neutral-600">
|
||||
Esta operação remove o cadastro do cliente e impede novos vínculos automáticos.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 text-sm text-neutral-600">
|
||||
<p>
|
||||
Confirme a exclusão de{" "}
|
||||
<span className="font-semibold text-neutral-900">{deleteTarget?.name ?? "empresa selecionada"}</span>.
|
||||
Deseja remover definitivamente{" "}
|
||||
<span className="font-semibold text-neutral-900">{deleteTarget?.name ?? "a empresa selecionada"}</span>?
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Registros históricos que apontem para a empresa poderão impedir a exclusão.
|
||||
Registros históricos podem impedir a exclusão. Usuários e tickets ainda vinculados serão desvinculados automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="gap-2 sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button"
|
|||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -1310,6 +1310,23 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<CardHeader>
|
||||
<CardTitle>Detalhes</CardTitle>
|
||||
<CardDescription>Resumo da máquina selecionada.</CardDescription>
|
||||
{machine ? (
|
||||
<CardAction>
|
||||
<div className="flex flex-col items-end gap-2 text-xs sm:text-sm">
|
||||
{companyName ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 font-semibold text-neutral-600 shadow-sm">
|
||||
{companyName}
|
||||
</div>
|
||||
) : null}
|
||||
<MachineStatusBadge status={effectiveStatus} />
|
||||
{!isActive ? (
|
||||
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
|
||||
Máquina desativada
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{!machine ? (
|
||||
|
|
@ -1317,7 +1334,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
) : (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="break-words text-2xl font-semibold text-neutral-900">{machine.hostname}</h1>
|
||||
|
|
@ -1330,19 +1347,6 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
{machine.authEmail ?? "E-mail não definido"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-sm">
|
||||
{companyName ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-600 shadow-sm">
|
||||
{companyName}
|
||||
</div>
|
||||
) : null}
|
||||
<MachineStatusBadge status={effectiveStatus} />
|
||||
{!isActive ? (
|
||||
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 text-xs font-semibold uppercase text-rose-700">
|
||||
Máquina desativada
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* ping integrado na badge de status */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
|
|
@ -1375,7 +1379,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
{isActive ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
|
||||
</Button>
|
||||
{machine.registeredBy ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
<Badge variant="outline" className="inline-flex h-9 items-center rounded-full border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
Registrada via {machine.registeredBy}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
|
@ -2667,14 +2671,14 @@ function MetricsGrid({ metrics, hardware, disks }: { metrics: MachineMetrics; ha
|
|||
const percentLabel = card.percent !== null ? `${Math.round(card.percent)}%` : "—"
|
||||
return (
|
||||
<div key={card.key} className="flex items-center gap-4 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="relative h-20 w-20">
|
||||
<div className="relative h-24 w-24">
|
||||
<ChartContainer
|
||||
config={{ usage: { label: card.label, color: card.color } }}
|
||||
className="h-20 w-20 aspect-square"
|
||||
className="h-24 w-24 aspect-square"
|
||||
>
|
||||
<RadialBarChart
|
||||
data={[{ name: card.label, value: percentValue }]}
|
||||
innerRadius="55%"
|
||||
innerRadius="68%"
|
||||
outerRadius="100%"
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
|
|
@ -2683,7 +2687,7 @@ function MetricsGrid({ metrics, hardware, disks }: { metrics: MachineMetrics; ha
|
|||
<RadialBar dataKey="value" cornerRadius={10} fill="var(--color-usage)" background />
|
||||
</RadialBarChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm font-semibold text-neutral-800">
|
||||
<div className="pointer-events-none absolute inset-[18%] flex items-center justify-center rounded-full bg-white/70 text-sm font-semibold text-neutral-900">
|
||||
{percentLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -494,11 +494,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
</div>
|
||||
<Dialog open={!!previewAttachment} onOpenChange={(open) => { if (!open) setPreviewAttachment(null) }}>
|
||||
<DialogContent className="max-w-3xl border border-slate-200 p-0">
|
||||
<DialogHeader className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<DialogTitle className="text-base font-semibold text-neutral-800">
|
||||
<DialogHeader className="relative px-4 py-3">
|
||||
<DialogTitle className="pr-10 text-base font-semibold text-neutral-800">
|
||||
{previewAttachment?.name ?? "Visualização do anexo"}
|
||||
</DialogTitle>
|
||||
<DialogClose className="inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
|
||||
<DialogClose className="absolute right-4 top-3 inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
|
||||
<X className="size-4" />
|
||||
</DialogClose>
|
||||
</DialogHeader>
|
||||
|
|
@ -611,13 +611,22 @@ function PortalCommentAttachmentCard({
|
|||
const target = await ensureUrl()
|
||||
if (!target) return
|
||||
try {
|
||||
const response = await fetch(target, { credentials: "include" })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected status ${response.status}`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = target
|
||||
link.href = blobUrl
|
||||
link.download = attachment.name ?? "anexo"
|
||||
link.rel = "noopener noreferrer"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.setTimeout(() => {
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error("Falha ao iniciar download do anexo", error)
|
||||
window.open(target, "_blank", "noopener,noreferrer")
|
||||
|
|
|
|||
|
|
@ -118,7 +118,10 @@ export function SectionCards() {
|
|||
|
||||
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
|
||||
<CardHeader className="gap-3">
|
||||
<CardDescription>Tempo médio da 1ª resposta</CardDescription>
|
||||
<CardDescription className="leading-snug">
|
||||
<span className="block">Tempo médio da</span>
|
||||
<span className="block">1ª resposta</span>
|
||||
</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||
{dashboard ? formatMinutes(dashboard.firstResponse.averageMinutes) : <Skeleton className="h-8 w-24" />}
|
||||
</CardTitle>
|
||||
|
|
@ -169,7 +172,10 @@ export function SectionCards() {
|
|||
|
||||
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
|
||||
<CardHeader className="gap-3">
|
||||
<CardDescription>Tickets resolvidos (7 dias)</CardDescription>
|
||||
<CardDescription className="leading-snug">
|
||||
<span className="block">Tickets resolvidos</span>
|
||||
<span className="block">(7 dias)</span>
|
||||
</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||
{dashboard ? dashboard.resolution.resolvedLast7d : <Skeleton className="h-8 w-12" />}
|
||||
</CardTitle>
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
|||
<h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
|
||||
<p className="text-sm text-neutral-600">{ticket.summary}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-neutral-600">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
|
||||
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
<span className="font-medium text-neutral-900">Solicitante: {ticket.requester.name}</span>
|
||||
|
|
@ -163,4 +163,4 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -514,9 +514,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
</Dialog>
|
||||
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
|
||||
<DialogContent className="max-w-3xl border border-slate-200 p-0">
|
||||
<DialogHeader className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<DialogTitle className="text-base font-semibold text-neutral-800">Visualização do anexo</DialogTitle>
|
||||
<DialogClose className="inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
|
||||
<DialogHeader className="relative px-4 py-3">
|
||||
<DialogTitle className="pr-10 text-base font-semibold text-neutral-800">Visualização do anexo</DialogTitle>
|
||||
<DialogClose className="absolute right-4 top-3 inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
|
||||
<X className="size-4" />
|
||||
</DialogClose>
|
||||
</DialogHeader>
|
||||
|
|
@ -614,13 +614,22 @@ function CommentAttachmentCard({
|
|||
const target = url ?? (await ensureUrl())
|
||||
if (!target) return
|
||||
try {
|
||||
const response = await fetch(target, { credentials: "include" })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected status ${response.status}`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = target
|
||||
link.href = blobUrl
|
||||
link.download = attachment.name ?? "anexo"
|
||||
link.rel = "noopener noreferrer"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.setTimeout(() => {
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error("Failed to download attachment", error)
|
||||
window.open(target, "_blank", "noopener,noreferrer")
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket";
|
||||
|
||||
export function TicketDetailStatic({ ticket }: { ticket: TicketWithDetails }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketSummaryHeader ticket={ticket} />
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<TicketTimeline ticket={ticket} />
|
||||
</div>
|
||||
<TicketDetailsPanel ticket={ticket} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -6,7 +6,6 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
|||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket";
|
||||
import { getTicketById } from "@/lib/mocks/tickets";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
|
||||
|
|
@ -16,9 +15,8 @@ import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
|||
import { useAuth } from "@/lib/auth-client";
|
||||
|
||||
export function TicketDetailView({ id }: { id: string }) {
|
||||
const isMockId = id.startsWith("ticket-");
|
||||
const { convexUserId } = useAuth();
|
||||
const shouldSkip = isMockId || !convexUserId;
|
||||
const shouldSkip = !convexUserId;
|
||||
const t = useQuery(
|
||||
api.tickets.getById,
|
||||
shouldSkip
|
||||
|
|
@ -29,40 +27,66 @@ export function TicketDetailView({ id }: { id: string }) {
|
|||
viewerId: convexUserId as Id<"users">,
|
||||
}
|
||||
);
|
||||
let ticket: TicketWithDetails | null = null;
|
||||
if (t) {
|
||||
ticket = mapTicketWithDetailsFromServer(t as unknown);
|
||||
} else if (isMockId) {
|
||||
ticket = getTicketById(id) ?? null;
|
||||
}
|
||||
if (!ticket) return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<div className="flex items-center gap-2"><Skeleton className="h-5 w-24" /><Skeleton className="h-5 w-20" /></div>
|
||||
<Skeleton className="h-7 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="space-y-4 p-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex items-center gap-2"><Skeleton className="h-4 w-28" /><Skeleton className="h-3 w-24" /></div>
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
const isLoading = shouldSkip || t === undefined;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
|
||||
<div className="flex items-center gap-2"><Skeleton className="h-5 w-24" /><Skeleton className="h-5 w-20" /></div>
|
||||
<Skeleton className="h-7 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="space-y-4 p-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex items-center gap-2"><Skeleton className="h-4 w-28" /><Skeleton className="h-3 w-24" /></div>
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!t) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<p className="text-base font-semibold text-neutral-900">Ticket não encontrado</p>
|
||||
<p className="text-sm text-neutral-600">O ticket solicitado não existe ou você não tem permissão para visualizá-lo.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
const ticket = mapTicketWithDetailsFromServer(t as unknown) as TicketWithDetails | null;
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<p className="text-base font-semibold text-neutral-900">Ticket não encontrado</p>
|
||||
<p className="text-sm text-neutral-600">O ticket solicitado não existe ou você não tem permissão para visualizá-lo.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketSummaryHeader ticket={ticket} />
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { format, formatDistanceToNowStrict } from "date-fns"
|
|||
import { ptBR } from "date-fns/locale"
|
||||
import { type LucideIcon, Code, FileText, Mail, MessageCircle, MessageSquare, Phone } from "lucide-react"
|
||||
|
||||
import { tickets as ticketsMock } from "@/lib/mocks/tickets"
|
||||
import type { Ticket, TicketChannel, TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
|
@ -110,7 +109,8 @@ export type TicketsTableProps = {
|
|||
tickets?: Ticket[]
|
||||
}
|
||||
|
||||
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||
export function TicketsTable({ tickets }: TicketsTableProps) {
|
||||
const safeTickets = tickets ?? []
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const router = useRouter()
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
<TableHead className="hidden w-[150px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell xl:w-[180px]">
|
||||
Empresa
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-[90px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell lg:w-[100px]">
|
||||
<TableHead className="hidden w-[90px] px-4 py-3 text-center text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell lg:w-[100px]">
|
||||
Prioridade
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[200px] lg:pl-10 xl:w-[230px] xl:pl-14">
|
||||
|
|
@ -170,7 +170,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => {
|
||||
{safeTickets.map((ticket) => {
|
||||
const ChannelIcon = channelIcon[ticket.channel] ?? MessageCircle
|
||||
|
||||
return (
|
||||
|
|
@ -240,9 +240,9 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
{((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-6 lg:pr-8`}>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell px-4`}>
|
||||
<div
|
||||
className="inline-flex"
|
||||
className="flex justify-center"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
|
|
@ -288,7 +288,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{tickets.length === 0 && (
|
||||
{safeTickets.length === 0 && (
|
||||
<Empty className="my-6">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ChangeEvent, useEffect, useMemo, useState } from "react"
|
|||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { COUNTRY_DIAL_CODES, type CountryDialCode } from "@/lib/country-codes"
|
||||
|
||||
export type CountryOption = {
|
||||
code: string
|
||||
|
|
@ -15,26 +16,7 @@ export type CountryOption = {
|
|||
formatLocal: (digits: string) => string
|
||||
}
|
||||
|
||||
const COUNTRY_OPTIONS: CountryOption[] = [
|
||||
{
|
||||
code: "BR",
|
||||
label: "Brasil",
|
||||
dialCode: "+55",
|
||||
flag: "🇧🇷",
|
||||
maxDigits: 11,
|
||||
formatLocal: formatBrazilPhone,
|
||||
},
|
||||
{
|
||||
code: "US",
|
||||
label: "Estados Unidos",
|
||||
dialCode: "+1",
|
||||
flag: "🇺🇸",
|
||||
maxDigits: 10,
|
||||
formatLocal: formatUsPhone,
|
||||
},
|
||||
]
|
||||
|
||||
const DEFAULT_COUNTRY = COUNTRY_OPTIONS[0]
|
||||
const DEFAULT_COUNTRY_CODE = "BR"
|
||||
|
||||
function formatBrazilPhone(digits: string): string {
|
||||
const cleaned = digits.slice(0, 11)
|
||||
|
|
@ -63,12 +45,18 @@ function extractDigits(value?: string | null): string {
|
|||
|
||||
function findCountryByValue(value?: string | null): CountryOption {
|
||||
const digits = extractDigits(value)
|
||||
return (
|
||||
COUNTRY_OPTIONS.find((option) => {
|
||||
const dialDigits = extractDigits(option.dialCode)
|
||||
return digits.startsWith(dialDigits) && digits.length > dialDigits.length
|
||||
}) ?? DEFAULT_COUNTRY
|
||||
)
|
||||
const dialDigits = extractDigits(DEFAULT_COUNTRY.dialCode)
|
||||
let matched: CountryOption = DEFAULT_COUNTRY
|
||||
let matchedLength = digits.startsWith(dialDigits) ? dialDigits.length : 0
|
||||
for (const option of COUNTRY_OPTIONS) {
|
||||
const optionDialDigits = extractDigits(option.dialCode)
|
||||
if (optionDialDigits.length === 0) continue
|
||||
if (digits.startsWith(optionDialDigits) && optionDialDigits.length > matchedLength) {
|
||||
matched = option
|
||||
matchedLength = optionDialDigits.length
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
function toLocalDigits(value: string | null | undefined, country: CountryOption): string {
|
||||
|
|
@ -88,9 +76,49 @@ function formatStoredValue(country: CountryOption, localDigits: string): string
|
|||
function placeholderFor(country: CountryOption): string {
|
||||
if (country.code === "BR") return "(11) 91234-5678"
|
||||
if (country.code === "US") return "(555) 123-4567"
|
||||
if (country.code === "CA") return "(416) 123-4567"
|
||||
return "Número de telefone"
|
||||
}
|
||||
|
||||
function formatGenericPhone(digits: string): string {
|
||||
const cleaned = digits.slice(0, 15)
|
||||
if (!cleaned) return ""
|
||||
return cleaned.replace(/(\d{3,4})(?=\d)/g, "$1 ").trim()
|
||||
}
|
||||
|
||||
function flagEmojiFor(code: string): string {
|
||||
if (!code || code.length !== 2) return "🏳️"
|
||||
const base = 127397
|
||||
const chars = Array.from(code.toUpperCase()).map((char) => base + char.charCodeAt(0))
|
||||
return String.fromCodePoint(...chars)
|
||||
}
|
||||
|
||||
const regionDisplayNames =
|
||||
typeof Intl !== "undefined" && "DisplayNames" in Intl
|
||||
? new Intl.DisplayNames(["pt-BR", "pt", "en"], { type: "region" })
|
||||
: null
|
||||
|
||||
const SPECIAL_FORMATS: Record<string, { maxDigits: number; formatLocal: (digits: string) => string }> = {
|
||||
BR: { maxDigits: 11, formatLocal: formatBrazilPhone },
|
||||
US: { maxDigits: 10, formatLocal: formatUsPhone },
|
||||
CA: { maxDigits: 10, formatLocal: formatUsPhone },
|
||||
}
|
||||
|
||||
const COUNTRY_OPTIONS: CountryOption[] = COUNTRY_DIAL_CODES.map((entry: CountryDialCode) => {
|
||||
const special = SPECIAL_FORMATS[entry.code]
|
||||
return {
|
||||
code: entry.code,
|
||||
label: regionDisplayNames?.of(entry.code) ?? entry.name ?? entry.code,
|
||||
dialCode: entry.dialCode,
|
||||
flag: flagEmojiFor(entry.code),
|
||||
maxDigits: special?.maxDigits ?? 15,
|
||||
formatLocal: special?.formatLocal ?? formatGenericPhone,
|
||||
}
|
||||
}).sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
||||
|
||||
const DEFAULT_COUNTRY =
|
||||
COUNTRY_OPTIONS.find((option) => option.code === DEFAULT_COUNTRY_CODE) ?? COUNTRY_OPTIONS[0]
|
||||
|
||||
export function PhoneInput({ value, onChange, className, id, name }: PhoneInputProps) {
|
||||
const [selectedCountry, setSelectedCountry] = useState<CountryOption>(DEFAULT_COUNTRY)
|
||||
const [localDigits, setLocalDigits] = useState<string>("")
|
||||
|
|
@ -131,7 +159,7 @@ export function PhoneInput({ value, onChange, className, id, name }: PhoneInputP
|
|||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-h-72 overflow-y-auto">
|
||||
{COUNTRY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.code} value={option.code}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue