feat: link tickets in comments and align admin sidebars
This commit is contained in:
parent
c35eb673d3
commit
b0f57009ac
15 changed files with 1606 additions and 424 deletions
|
|
@ -20,6 +20,14 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useQuery } from "convex/react"
|
||||
|
|
@ -356,6 +364,74 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
})
|
||||
}, [combinedBaseUsers, usersSearch, usersCompanyFilter])
|
||||
|
||||
const [teamPageSize, setTeamPageSize] = useState<number>(10)
|
||||
const [teamPageIndex, setTeamPageIndex] = useState<number>(0)
|
||||
const teamTotal = filteredTeamUsers.length
|
||||
const teamPageCount = Math.max(1, Math.ceil(teamTotal / teamPageSize))
|
||||
const teamPaginated = useMemo(
|
||||
() => filteredTeamUsers.slice(teamPageIndex * teamPageSize, teamPageIndex * teamPageSize + teamPageSize),
|
||||
[filteredTeamUsers, teamPageIndex, teamPageSize]
|
||||
)
|
||||
const teamStart = teamTotal === 0 ? 0 : teamPageIndex * teamPageSize + 1
|
||||
const teamEnd = teamTotal === 0 ? 0 : Math.min(teamTotal, teamPageIndex * teamPageSize + teamPageSize)
|
||||
|
||||
useEffect(() => {
|
||||
setTeamPageIndex(0)
|
||||
}, [teamSearch, teamRoleFilter, teamCompanyFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (teamPageIndex > teamPageCount - 1) {
|
||||
setTeamPageIndex(Math.max(0, teamPageCount - 1))
|
||||
}
|
||||
}, [teamPageIndex, teamPageCount])
|
||||
|
||||
const [usersPageSize, setUsersPageSize] = useState<number>(10)
|
||||
const [usersPageIndex, setUsersPageIndex] = useState<number>(0)
|
||||
const usersTotal = filteredCombinedUsers.length
|
||||
const usersPageCount = Math.max(1, Math.ceil(usersTotal / usersPageSize))
|
||||
const usersPaginated = useMemo(
|
||||
() =>
|
||||
filteredCombinedUsers.slice(
|
||||
usersPageIndex * usersPageSize,
|
||||
usersPageIndex * usersPageSize + usersPageSize
|
||||
),
|
||||
[filteredCombinedUsers, usersPageIndex, usersPageSize]
|
||||
)
|
||||
const usersStart = usersTotal === 0 ? 0 : usersPageIndex * usersPageSize + 1
|
||||
const usersEnd = usersTotal === 0 ? 0 : Math.min(usersTotal, usersPageIndex * usersPageSize + usersPageSize)
|
||||
|
||||
useEffect(() => {
|
||||
setUsersPageIndex(0)
|
||||
}, [usersSearch, usersTypeFilter, usersCompanyFilter])
|
||||
|
||||
useEffect(() => {
|
||||
if (usersPageIndex > usersPageCount - 1) {
|
||||
setUsersPageIndex(Math.max(0, usersPageCount - 1))
|
||||
}
|
||||
}, [usersPageIndex, usersPageCount])
|
||||
|
||||
const [invitesPageSize, setInvitesPageSize] = useState<number>(10)
|
||||
const [invitesPageIndex, setInvitesPageIndex] = useState<number>(0)
|
||||
const invitesTotal = invites.length
|
||||
const invitesPageCount = Math.max(1, Math.ceil(invitesTotal / invitesPageSize))
|
||||
const paginatedInvites = useMemo(
|
||||
() => invites.slice(invitesPageIndex * invitesPageSize, invitesPageIndex * invitesPageSize + invitesPageSize),
|
||||
[invites, invitesPageIndex, invitesPageSize]
|
||||
)
|
||||
const invitesStart = invitesTotal === 0 ? 0 : invitesPageIndex * invitesPageSize + 1
|
||||
const invitesEnd =
|
||||
invitesTotal === 0 ? 0 : Math.min(invitesTotal, invitesPageIndex * invitesPageSize + invitesPageSize)
|
||||
|
||||
useEffect(() => {
|
||||
setInvitesPageIndex(0)
|
||||
}, [invitesTotal])
|
||||
|
||||
useEffect(() => {
|
||||
if (invitesPageIndex > invitesPageCount - 1) {
|
||||
setInvitesPageIndex(Math.max(0, invitesPageCount - 1))
|
||||
}
|
||||
}, [invitesPageIndex, invitesPageCount])
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
|
|
@ -1008,104 +1084,168 @@ async function handleDeleteUser() {
|
|||
<CardTitle>Equipe cadastrada</CardTitle>
|
||||
<CardDescription>Usuários com acesso ao sistema. Edite papéis, dados pessoais ou gere uma nova senha.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="w-10 py-3 pr-2 font-medium">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
||||
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
<CardContent className="space-y-4">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="min-w-full table-fixed text-sm">
|
||||
<thead className="bg-slate-100/80">
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
|
||||
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
||||
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Máquinas</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{teamPaginated.length > 0 ? (
|
||||
teamPaginated.map((user) => (
|
||||
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
|
||||
<td className="px-4 py-3 first:pl-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={teamSelection.has(user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setTeamSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(user.id)
|
||||
else next.delete(user.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">{user.name || "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{formatRole(user.role)}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{(() => {
|
||||
const list = machinesByUserEmail.get((user.email ?? "").toLowerCase()) ?? []
|
||||
if (list.length === 0) return "—"
|
||||
const names = list.map((m) => m.hostname || m.id)
|
||||
const head = names.slice(0, 2).join(", ")
|
||||
const extra = names.length > 2 ? ` +${names.length - 2}` : ""
|
||||
return (
|
||||
<span className="text-xs font-medium" title={names.join(", ")}>
|
||||
{head}
|
||||
{extra}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="px-4 py-3 last:pr-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setEditUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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
|
||||
setDeleteUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||
{teamUsers.length === 0
|
||||
? "Nenhum usuário cadastrado até o momento."
|
||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
||||
<div>{teamTotal === 0 ? "Nenhum registro" : `Mostrando ${teamStart}-${teamEnd} de ${teamTotal}`}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm">
|
||||
<span>Itens por página</span>
|
||||
<Select
|
||||
value={`${teamPageSize}`}
|
||||
onValueChange={(value) => {
|
||||
const next = Number(value)
|
||||
setTeamPageSize(next)
|
||||
setTeamPageIndex(0)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
<SelectValue placeholder={`${teamPageSize}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{[10, 20, 30, 50].map((n) => (
|
||||
<SelectItem key={`team-page-${n}`} value={`${n}`}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
disabled={teamPageIndex === 0}
|
||||
onClick={() => setTeamPageIndex((previous) => Math.max(0, previous - 1))}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Empresa</th>
|
||||
<th className="py-3 pr-4 font-medium">Máquinas</th>
|
||||
{/* Espaço removido */}
|
||||
<th className="py-3 pr-4 font-medium">Criado em</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredTeamUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={teamSelection.has(user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setTeamSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(user.id)
|
||||
else next.delete(user.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<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>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{(() => {
|
||||
const list = machinesByUserEmail.get((user.email ?? '').toLowerCase()) ?? []
|
||||
if (list.length === 0) return '—'
|
||||
const names = list.map((m) => m.hostname || m.id)
|
||||
const head = names.slice(0, 2).join(', ')
|
||||
const extra = names.length > 2 ? ` +${names.length - 2}` : ''
|
||||
return <span className="text-xs font-medium" title={names.join(', ')}>{head}{extra}</span>
|
||||
})()}
|
||||
</td>
|
||||
{/* Espaço removido */}
|
||||
<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"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setEditUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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
|
||||
setDeleteUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredTeamUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-6 text-center text-neutral-500">
|
||||
{teamUsers.length === 0
|
||||
? "Nenhum usuário cadastrado até o momento."
|
||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{teamPageIndex + 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
disabled={teamPageIndex >= teamPageCount - 1}
|
||||
onClick={() =>
|
||||
setTeamPageIndex((previous) => Math.min(teamPageCount - 1, previous + 1))
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -1226,112 +1366,193 @@ async function handleDeleteUser() {
|
|||
<CardTitle>Usuários</CardTitle>
|
||||
<CardDescription>Pessoas e máquinas com acesso ao sistema.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="w-10 py-3 pr-2 font-medium">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={usersSelection.size > 0 && usersSelection.size === filteredCombinedUsers.length || (usersSelection.size > 0 && usersSelection.size < filteredCombinedUsers.length && "indeterminate")}
|
||||
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
<CardContent className="space-y-4">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="min-w-full table-fixed text-sm">
|
||||
<thead className="bg-slate-100/80">
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
|
||||
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
usersSelection.size > 0 &&
|
||||
usersSelection.size === filteredCombinedUsers.length
|
||||
? true
|
||||
: usersSelection.size > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Tipo</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Perfil</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{usersPaginated.length > 0 ? (
|
||||
usersPaginated.map((user) => (
|
||||
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
|
||||
<td className="px-4 py-3 first:pl-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={usersSelection.has(user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setUsersSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(user.id)
|
||||
else next.delete(user.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">
|
||||
{user.name || (user.role === "machine" ? "Máquina" : "—")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{user.role === "machine" ? "Máquina" : "Pessoa"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{user.role === "machine" ? (
|
||||
user.machinePersona ? (
|
||||
<Badge
|
||||
variant={machinePersonaBadgeVariant(user.machinePersona)}
|
||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{formatMachinePersona(user.machinePersona)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-neutral-500">Sem persona</span>
|
||||
)
|
||||
) : (
|
||||
formatRole(user.role)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="px-4 py-3 last:pr-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setEditUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
{user.role === "machine" ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
href={
|
||||
extractMachineId(user.email)
|
||||
? `/admin/machines/${extractMachineId(user.email)}`
|
||||
: "/admin/machines"
|
||||
}
|
||||
>
|
||||
Detalhes da máquina
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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
|
||||
setDeleteUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||
{combinedBaseUsers.length === 0
|
||||
? "Nenhum usuário cadastrado até o momento."
|
||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
||||
<div>{usersTotal === 0 ? "Nenhum registro" : `Mostrando ${usersStart}-${usersEnd} de ${usersTotal}`}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm">
|
||||
<span>Itens por página</span>
|
||||
<Select
|
||||
value={`${usersPageSize}`}
|
||||
onValueChange={(value) => {
|
||||
const next = Number(value)
|
||||
setUsersPageSize(next)
|
||||
setUsersPageIndex(0)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
<SelectValue placeholder={`${usersPageSize}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{[10, 20, 30, 50].map((n) => (
|
||||
<SelectItem key={`users-page-${n}`} value={`${n}`}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
disabled={usersPageIndex === 0}
|
||||
onClick={() => setUsersPageIndex((previous) => Math.max(0, previous - 1))}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Tipo</th>
|
||||
<th className="py-3 pr-4 font-medium">Perfil</th>
|
||||
<th className="py-3 pr-4 font-medium">Empresa</th>
|
||||
{/* Espaço removido */}
|
||||
<th className="py-3 pr-4 font-medium">Criado em</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredCombinedUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={usersSelection.has(user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setUsersSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(user.id)
|
||||
else next.delete(user.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || (user.role === "machine" ? "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.role === "machine" ? "Máquina" : "Pessoa"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{user.role === "machine" ? (
|
||||
user.machinePersona ? (
|
||||
<Badge variant={machinePersonaBadgeVariant(user.machinePersona)} className="rounded-full px-3 py-1 text-xs font-medium">
|
||||
{formatMachinePersona(user.machinePersona)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-neutral-500">Sem persona</span>
|
||||
)
|
||||
) : (
|
||||
formatRole(user.role)
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||
{/* Espaço removido */}
|
||||
<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"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setEditUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
{user.role === "machine" ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={(extractMachineId(user.email) ? `/admin/machines/${extractMachineId(user.email)}` : "/admin/machines")}>Detalhes da máquina</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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
|
||||
setDeleteUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredCombinedUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-6 text-center text-neutral-500">
|
||||
{combinedBaseUsers.length === 0
|
||||
? "Nenhum usuário cadastrado até o momento."
|
||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{usersPageIndex + 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
disabled={usersPageIndex >= usersPageCount - 1}
|
||||
onClick={() =>
|
||||
setUsersPageIndex((previous) => Math.min(usersPageCount - 1, previous + 1))
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
|
@ -1422,113 +1643,172 @@ async function handleDeleteUser() {
|
|||
<CardTitle>Convites emitidos</CardTitle>
|
||||
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="w-10 py-3 pr-2 font-medium">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
|
||||
onCheckedChange={(value) => toggleInvitesSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="py-3 pr-4 font-medium">Colaborador</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
{/* Espaço removido */}
|
||||
<th className="py-3 pr-4 font-medium">Expira em</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{invites.map((invite) => (
|
||||
<tr key={invite.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={inviteSelection.has(invite.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setInviteSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(invite.id)
|
||||
else next.delete(invite.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
<CardContent className="space-y-4">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="min-w-full table-fixed text-sm">
|
||||
<thead className="bg-slate-100/80">
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
|
||||
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
|
||||
onCheckedChange={(value) => toggleInvitesSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Colaborador</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Expira em</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Status</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{paginatedInvites.length > 0 ? (
|
||||
paginatedInvites.map((invite) => (
|
||||
<tr key={invite.id} className="transition-colors hover:bg-slate-50/80">
|
||||
<td className="px-4 py-3 first:pl-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={inviteSelection.has(invite.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setInviteSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(invite.id)
|
||||
else next.delete(invite.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 first:pl-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
||||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{formatRole(invite.role)}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant={inviteStatusVariant(invite.status)}
|
||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{formatStatus(invite.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 last:pr-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
{invite.status === "pending" && canManageInvite(invite.role) ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 transition-colors hover:bg-red-500/10"
|
||||
onClick={() => setRevokeDialogInviteId(invite.id)}
|
||||
disabled={revokingId === invite.id}
|
||||
>
|
||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||
</Button>
|
||||
) : null}
|
||||
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-amber-400 text-amber-600 hover:bg-amber-50"
|
||||
onClick={() => handleReactivate(invite)}
|
||||
disabled={reactivatingId === invite.id}
|
||||
>
|
||||
{reactivatingId === invite.id ? "Reativando..." : "Reativar"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||
Nenhum convite emitido até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
||||
<div>{invitesTotal === 0 ? "Nenhum registro" : `Mostrando ${invitesStart}-${invitesEnd} de ${invitesTotal}`}</div>
|
||||
<div className="flex flex-col-reverse gap-3 md:flex-row md:items-center md:gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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={selectedInvites.length === 0 || isBulkRevokingInvites}
|
||||
onClick={() => setBulkRevokeInvitesOpen(true)}
|
||||
>
|
||||
<IconTrash className="size-4" /> Revogar selecionados
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm">
|
||||
<span>Itens por página</span>
|
||||
<Select
|
||||
value={`${invitesPageSize}`}
|
||||
onValueChange={(value) => {
|
||||
const next = Number(value)
|
||||
setInvitesPageSize(next)
|
||||
setInvitesPageIndex(0)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
<SelectValue placeholder={`${invitesPageSize}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{[10, 20, 30, 50].map((n) => (
|
||||
<SelectItem key={`invites-page-${n}`} value={`${n}`}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
disabled={invitesPageIndex === 0}
|
||||
onClick={() => setInvitesPageIndex((previous) => Math.max(0, previous - 1))}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
||||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
|
||||
{/* Espaço removido */}
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge
|
||||
variant={inviteStatusVariant(invite.status)}
|
||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{formatStatus(invite.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
{invite.status === "pending" && canManageInvite(invite.role) ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 transition-colors hover:bg-red-500/10"
|
||||
onClick={() => setRevokeDialogInviteId(invite.id)}
|
||||
disabled={revokingId === invite.id}
|
||||
>
|
||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||
</Button>
|
||||
) : null}
|
||||
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-amber-400 text-amber-600 hover:bg-amber-50"
|
||||
onClick={() => handleReactivate(invite)}
|
||||
disabled={reactivatingId === invite.id}
|
||||
>
|
||||
{reactivatingId === invite.id ? "Reativando..." : "Reativar"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{invites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-6 text-center text-neutral-500">
|
||||
Nenhum convite emitido até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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={selectedInvites.length === 0 || isBulkRevokingInvites}
|
||||
onClick={() => setBulkRevokeInvitesOpen(true)}
|
||||
>
|
||||
<IconTrash className="size-4" /> Revogar selecionados
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{invitesPageIndex + 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
disabled={invitesPageIndex >= invitesPageCount - 1}
|
||||
onClick={() =>
|
||||
setInvitesPageIndex((previous) => Math.min(invitesPageCount - 1, previous + 1))
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -758,7 +758,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
|||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table className="w-full table-auto">
|
||||
<TableHeader className="bg-muted border-b">
|
||||
<TableHeader className="bg-slate-100/80 border-b border-slate-200">
|
||||
<TableRow>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Contratos ativos</TableHead>
|
||||
|
|
@ -787,7 +787,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
|||
return (
|
||||
<TableRow key={company.id} className="hover:bg-muted/40">
|
||||
<TableCell className="align-middle">
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-full flex-col justify-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-foreground">{company.name}</p>
|
||||
{company.isAvulso ? <Badge variant="outline">Avulso</Badge> : null}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ const ROLE_LABEL: Record<AdminAccount["role"], string> = {
|
|||
COLLABORATOR: "Colaborador",
|
||||
}
|
||||
|
||||
const NO_CONTACT_VALUE = "__none__"
|
||||
|
||||
function createId(prefix: string) {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return `${prefix}-${crypto.randomUUID()}`
|
||||
|
|
@ -612,16 +614,16 @@ function CompanySectionSheet({ editor, onClose, onUpdated }: CompanySectionSheet
|
|||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
||||
<SheetContent className="flex w-full max-w-4xl flex-col gap-0 p-0">
|
||||
<SheetHeader className="border-b border-border/60 px-6 py-4">
|
||||
<SheetTitle className="text-base font-semibold">
|
||||
<SheetContent className="flex w-full max-w-none flex-col gap-0 bg-background p-0 sm:max-w-[48rem] lg:max-w-[60rem] xl:max-w-[68rem]">
|
||||
<SheetHeader className="border-b border-border/60 px-10 py-7">
|
||||
<SheetTitle className="text-xl font-semibold">
|
||||
{editor?.section === "contacts" ? "Contatos da empresa" : null}
|
||||
{editor?.section === "locations" ? "Localizações e unidades" : null}
|
||||
{editor?.section === "contracts" ? "Contratos ativos" : null}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">{content}</div>
|
||||
<SheetFooter className="border-t border-border/60 px-6 py-4">
|
||||
<div className="flex-1 overflow-y-auto px-10 py-8">{content}</div>
|
||||
<SheetFooter className="border-t border-border/60 px-10 py-5">
|
||||
<div className="flex w-full items-center justify-end">
|
||||
{isSubmitting ? (
|
||||
<span className="text-sm text-muted-foreground">Salvando...</span>
|
||||
|
|
@ -674,11 +676,11 @@ function ContactsEditor({
|
|||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Contatos estratégicos</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<form onSubmit={submit} className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-base font-semibold text-foreground">Contatos estratégicos</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cadastre responsáveis por aprovação, faturamento e comunicação.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -686,6 +688,7 @@ function ContactsEditor({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start sm:self-auto"
|
||||
onClick={() =>
|
||||
fieldArray.append({
|
||||
id: createId("contact"),
|
||||
|
|
@ -703,18 +706,18 @@ function ContactsEditor({
|
|||
})
|
||||
}
|
||||
>
|
||||
<IconPlus className="mr-1 size-3.5" />
|
||||
<IconPlus className="mr-2 size-4" />
|
||||
Novo contato
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{fieldArray.fields.map((field, index) => {
|
||||
const errors = form.formState.errors.contacts?.[index]
|
||||
return (
|
||||
<Card key={field.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-base font-semibold">Contato #{index + 1}</CardTitle>
|
||||
<Card key={field.id} className="border border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-lg font-semibold">Contato #{index + 1}</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -725,29 +728,39 @@ function ContactsEditor({
|
|||
<IconTrash className="size-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label>Nome completo</Label>
|
||||
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Nome completo
|
||||
</Label>
|
||||
<Input {...form.register(`contacts.${index}.fullName` as const)} />
|
||||
<FieldError message={errors?.fullName?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>E-mail</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
E-mail
|
||||
</Label>
|
||||
<Input {...form.register(`contacts.${index}.email` as const)} />
|
||||
<FieldError message={errors?.email?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Telefone</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Telefone
|
||||
</Label>
|
||||
<Input {...form.register(`contacts.${index}.phone` as const)} placeholder="(11) 99999-0000" />
|
||||
<FieldError message={errors?.phone?.message as string | undefined} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>WhatsApp</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
WhatsApp
|
||||
</Label>
|
||||
<Input {...form.register(`contacts.${index}.whatsapp` as const)} placeholder="(11) 99999-0000" />
|
||||
<FieldError message={errors?.whatsapp?.message as string | undefined} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Função</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Função
|
||||
</Label>
|
||||
<Controller
|
||||
name={`contacts.${index}.role` as const}
|
||||
control={form.control}
|
||||
|
|
@ -767,12 +780,16 @@ function ContactsEditor({
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Cargo interno</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Cargo interno
|
||||
</Label>
|
||||
<Input {...form.register(`contacts.${index}.title` as const)} placeholder="ex.: Coordenador TI" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Preferências de contato</Label>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Preferências de contato
|
||||
</Label>
|
||||
<Controller
|
||||
name={`contacts.${index}.preference` as const}
|
||||
control={form.control}
|
||||
|
|
@ -785,26 +802,28 @@ function ContactsEditor({
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="md:col-span-2 flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={form.watch(`contacts.${index}.canAuthorizeTickets` as const)}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue(`contacts.${index}.canAuthorizeTickets` as const, Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Pode autorizar tickets</span>
|
||||
<span className="text-sm font-medium text-neutral-700">Pode autorizar tickets</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="md:col-span-2 flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
|
||||
<Checkbox
|
||||
checked={form.watch(`contacts.${index}.canApproveCosts` as const)}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue(`contacts.${index}.canApproveCosts` as const, Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Pode aprovar custos</span>
|
||||
<span className="text-sm font-medium text-neutral-700">Pode aprovar custos</span>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Anotações</Label>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Anotações
|
||||
</Label>
|
||||
<Textarea {...form.register(`contacts.${index}.notes` as const)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -813,11 +832,11 @@ function ContactsEditor({
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Button type="submit" className="whitespace-nowrap sm:w-auto">
|
||||
<IconPencil className="mr-2 size-4" />
|
||||
Salvar contatos
|
||||
</Button>
|
||||
|
|
@ -862,18 +881,19 @@ function LocationsEditor({
|
|||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Localizações</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Define unidades críticas, data centers e filiais para atendimento dedicado.
|
||||
<form onSubmit={submit} className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-base font-semibold text-foreground">Localizações</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Defina unidades críticas, data centers e filiais para atendimento dedicado.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start sm:self-auto"
|
||||
onClick={() =>
|
||||
fieldArray.append({
|
||||
id: createId("location"),
|
||||
|
|
@ -886,18 +906,18 @@ function LocationsEditor({
|
|||
})
|
||||
}
|
||||
>
|
||||
<IconPlus className="mr-1 size-3.5" />
|
||||
<IconPlus className="mr-2 size-4" />
|
||||
Nova unidade
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{fieldArray.fields.map((field, index) => {
|
||||
const errors = form.formState.errors.locations?.[index]
|
||||
return (
|
||||
<Card key={field.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-base font-semibold">Unidade #{index + 1}</CardTitle>
|
||||
<Card key={field.id} className="border border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-lg font-semibold">Unidade #{index + 1}</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -908,14 +928,14 @@ function LocationsEditor({
|
|||
<IconTrash className="size-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label>Nome</Label>
|
||||
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nome</Label>
|
||||
<Input {...form.register(`locations.${index}.name` as const)} />
|
||||
<FieldError message={errors?.name?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Tipo</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Tipo</Label>
|
||||
<Controller
|
||||
name={`locations.${index}.type` as const}
|
||||
control={form.control}
|
||||
|
|
@ -935,18 +955,25 @@ function LocationsEditor({
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Contato responsável</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Contato responsável
|
||||
</Label>
|
||||
<Controller
|
||||
name={`locations.${index}.responsibleContactId` as const}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value ?? ""} onValueChange={(value) => field.onChange(value || null)}>
|
||||
<Select
|
||||
value={field.value ?? NO_CONTACT_VALUE}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === NO_CONTACT_VALUE ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Nenhum</SelectItem>
|
||||
<SelectItem value={NO_CONTACT_VALUE}>Nenhum</SelectItem>
|
||||
{contacts.map((contact) => (
|
||||
<SelectItem key={contact.id} value={contact.id}>
|
||||
{contact.fullName}
|
||||
|
|
@ -957,8 +984,10 @@ function LocationsEditor({
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Notas</Label>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Notas
|
||||
</Label>
|
||||
<Textarea {...form.register(`locations.${index}.notes` as const)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -967,11 +996,11 @@ function LocationsEditor({
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Button type="submit" className="whitespace-nowrap sm:w-auto">
|
||||
<IconPencil className="mr-2 size-4" />
|
||||
Salvar localizações
|
||||
</Button>
|
||||
|
|
@ -1019,11 +1048,11 @@ function ContractsEditor({
|
|||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Contratos</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<form onSubmit={submit} className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-base font-semibold text-foreground">Contratos</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Registre vigência, escopo e condições para este cliente.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1031,6 +1060,7 @@ function ContractsEditor({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start sm:self-auto"
|
||||
onClick={() =>
|
||||
fieldArray.append({
|
||||
id: createId("contract"),
|
||||
|
|
@ -1047,18 +1077,18 @@ function ContractsEditor({
|
|||
})
|
||||
}
|
||||
>
|
||||
<IconPlus className="mr-1 size-3.5" />
|
||||
<IconPlus className="mr-2 size-4" />
|
||||
Novo contrato
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{fieldArray.fields.map((field, index) => {
|
||||
const errors = form.formState.errors.contracts?.[index]
|
||||
return (
|
||||
<Card key={field.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-base font-semibold">Contrato #{index + 1}</CardTitle>
|
||||
<Card key={field.id} className="border border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-lg font-semibold">Contrato #{index + 1}</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -1069,9 +1099,11 @@ function ContractsEditor({
|
|||
<IconTrash className="size-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label>Tipo</Label>
|
||||
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Tipo
|
||||
</Label>
|
||||
<Controller
|
||||
name={`contracts.${index}.contractType` as const}
|
||||
control={form.control}
|
||||
|
|
@ -1091,34 +1123,46 @@ function ContractsEditor({
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>SKU/plano</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
SKU/plano
|
||||
</Label>
|
||||
<Input {...form.register(`contracts.${index}.planSku` as const)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Início</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Início
|
||||
</Label>
|
||||
<Input type="date" {...form.register(`contracts.${index}.startDate` as const)} />
|
||||
<FieldError message={errors?.startDate?.message as string | undefined} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Fim</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Fim
|
||||
</Label>
|
||||
<Input type="date" {...form.register(`contracts.${index}.endDate` as const)} />
|
||||
<FieldError message={errors?.endDate?.message as string | undefined} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Renovação</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Renovação
|
||||
</Label>
|
||||
<Input type="date" {...form.register(`contracts.${index}.renewalDate` as const)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Valor (R$)</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Valor (R$)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...form.register(`contracts.${index}.price` as const, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Escopo</Label>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Escopo
|
||||
</Label>
|
||||
<Controller
|
||||
name={`contracts.${index}.scope` as const}
|
||||
control={form.control}
|
||||
|
|
@ -1138,8 +1182,10 @@ function ContractsEditor({
|
|||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Observações</Label>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Observações
|
||||
</Label>
|
||||
<Textarea {...form.register(`contracts.${index}.notes` as const)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -1148,11 +1194,11 @@ function ContractsEditor({
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Button type="submit" className="whitespace-nowrap sm:w-auto">
|
||||
<IconPencil className="mr-2 size-4" />
|
||||
Salvar contratos
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue