add user
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
|
||||
import { NavMain } from '@/components/nav-main'
|
||||
import { OrgSwitcher } from '@/components/org-switcher'
|
||||
import { WorkspaceSwitcher } from '@/components/workspace-switcher'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -42,11 +42,6 @@ const data = {
|
||||
url: '/admins',
|
||||
icon: ShieldUserIcon
|
||||
}
|
||||
// {
|
||||
// title: 'Webhooks',
|
||||
// url: '/webhooks',
|
||||
// icon: WebhookIcon
|
||||
// }
|
||||
],
|
||||
navContent: [
|
||||
{
|
||||
@@ -76,7 +71,7 @@ export function AppSidebar({ orgs = [] }) {
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<OrgSwitcher orgs={orgs} />
|
||||
<WorkspaceSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain data={data} />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@repo/ui/components/ui/table'
|
||||
import { cn } from '@repo/ui/lib/utils'
|
||||
import { DataTablePagination } from './pagination'
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
@@ -145,7 +146,7 @@ export function DataTable<TData, TValue>({
|
||||
<CardContent>
|
||||
{children}
|
||||
|
||||
<Table_>
|
||||
<Table_ className="table-auto">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
@@ -154,7 +155,13 @@ export function DataTable<TData, TValue>({
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="p-2.5">
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
'p-2.5',
|
||||
header.column.columnDef.meta?.className
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
@@ -176,7 +183,13 @@ export function DataTable<TData, TValue>({
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="p-2.5">
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'p-2.5',
|
||||
cell.column.columnDef.meta?.className
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
|
||||
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import { useLocation, useParams } from 'react-router'
|
||||
|
||||
import {
|
||||
@@ -23,23 +23,60 @@ import {
|
||||
} from '@repo/ui/components/ui/sidebar'
|
||||
import { initials } from '@repo/ui/lib/utils'
|
||||
|
||||
type Org = {
|
||||
type Workspace = {
|
||||
id: string
|
||||
name: string
|
||||
cnpj: string
|
||||
}
|
||||
|
||||
export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
||||
type WorkspaceContextProps = {
|
||||
workspaces: Workspace[]
|
||||
activeWorkspace: Workspace | null
|
||||
setActiveWorkspace: React.Dispatch<React.SetStateAction<Workspace | null>>
|
||||
}
|
||||
|
||||
const WorkspaceContext = createContext<WorkspaceContextProps | null>(null)
|
||||
|
||||
export function useWorksapce() {
|
||||
const ctx = useContext(WorkspaceContext)
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('WorkspaceContext is null')
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function WorkspaceProvider({
|
||||
workspaces,
|
||||
children
|
||||
}: {
|
||||
workspaces: Workspace[]
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { orgid } = useParams()
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(
|
||||
() => workspaces.find((ws) => ws.id === orgid) ?? null
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkspaceContext
|
||||
value={{ workspaces, activeWorkspace, setActiveWorkspace }}
|
||||
>
|
||||
{children}
|
||||
</WorkspaceContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceSwitcher() {
|
||||
const location = useLocation()
|
||||
const { isMobile, state } = useSidebar()
|
||||
const { orgid } = useParams()
|
||||
const org = orgs.find((org) => org.id === orgid) as Org
|
||||
const [activeOrg, setActiveOrg] = useState<Org>(org)
|
||||
const { activeWorkspace, setActiveWorkspace, workspaces } = useWorksapce()
|
||||
const [, fragment, _] = location.pathname.slice(1).split('/')
|
||||
|
||||
const onSelect = (org: Org) => {
|
||||
setActiveOrg(org)
|
||||
window.location.assign(`/${org.id}/${fragment}`)
|
||||
const onSelect = (ws: Workspace) => {
|
||||
setActiveWorkspace(ws)
|
||||
window.location.assign(`/${ws.id}/${fragment}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -55,17 +92,20 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
||||
className="aria-expanded:border flex aspect-square size-8 items-center justify-center rounded-lg"
|
||||
aria-expanded={state === 'expanded'}
|
||||
>
|
||||
{initials(activeOrg?.name)}
|
||||
{initials(activeWorkspace?.name)}
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{activeOrg?.name}</span>
|
||||
<span className="truncate font-medium">
|
||||
{activeWorkspace?.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{formatCNPJ(activeOrg?.cnpj)}
|
||||
{formatCNPJ(activeWorkspace?.cnpj)}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="start"
|
||||
@@ -75,20 +115,23 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||
Empresas
|
||||
</DropdownMenuLabel>
|
||||
{orgs.map((org, index) => (
|
||||
|
||||
{workspaces.map((workspace, index) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
onClick={() => onSelect(org)}
|
||||
onClick={() => onSelect(workspace)}
|
||||
className="group gap-2 p-2 cursor-pointer aria-selected:pointer-events-none"
|
||||
aria-selected={org.id === activeOrg.id}
|
||||
aria-selected={workspace.id === activeWorkspace?.id}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-lg border">
|
||||
{initials(org?.name)}
|
||||
{initials(workspace?.name)}
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{org?.name}</span>
|
||||
<span className="truncate font-medium">
|
||||
{workspace?.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{formatCNPJ(org?.cnpj)}
|
||||
{formatCNPJ(workspace?.cnpj)}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenuShortcut className="not-group-aria-selected:hidden">
|
||||
@@ -96,7 +139,9 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem className="gap-2 p-2">
|
||||
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||
<PlusIcon className="size-4" />
|
||||
@@ -43,4 +43,4 @@ export default async function handleRequest(
|
||||
}
|
||||
|
||||
// https://reactrouter.com/how-to/suspense#timeouts
|
||||
export const streamTimeout = 6_000
|
||||
export const streamTimeout = 7_000
|
||||
|
||||
@@ -31,9 +31,15 @@ export function request({
|
||||
const requestId = context.get(requestIdContext) as string
|
||||
const user = context.get(userContext) as User
|
||||
const url_ = new URL(url, context.cloudflare.env.API_URL)
|
||||
const headers = new Headers(
|
||||
Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _headers)
|
||||
)
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${user.accessToken}`
|
||||
})
|
||||
|
||||
if (_headers instanceof Headers) {
|
||||
_headers.forEach((value, key) => headers.set(key, value))
|
||||
} else {
|
||||
Object.entries(_headers).forEach(([key, value]) => headers.set(key, value))
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`
|
||||
|
||||
@@ -119,9 +119,6 @@ export default function Route({ loaderData: { data } }) {
|
||||
{selectedRows.length ? (
|
||||
<>
|
||||
<div className="flex gap-2.5 items-center">
|
||||
<Button variant="outline">
|
||||
<TagIcon /> Marcador
|
||||
</Button>
|
||||
<DropdownMenuExport
|
||||
headers={headers}
|
||||
selectedRows={selectedRows}
|
||||
|
||||
@@ -43,7 +43,14 @@ export const columns: ColumnDef<Order>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Data de venc.',
|
||||
header: 'Comprado em',
|
||||
cell: ({ row }) => {
|
||||
const createdAt = new Date(row.original.create_date)
|
||||
return formatted.format(createdAt)
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Vencimento em',
|
||||
cell: ({ row }) => {
|
||||
try {
|
||||
const dueDate = new Date(row.original.due_date)
|
||||
@@ -54,10 +61,14 @@ export const columns: ColumnDef<Order>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Comprado em',
|
||||
header: 'Pago em',
|
||||
cell: ({ row }) => {
|
||||
const createdAt = new Date(row.original.create_date)
|
||||
return formatted.format(createdAt)
|
||||
if (row.original.payment_date) {
|
||||
const createdAt = new Date(row.original.payment_date)
|
||||
return formatted.format(createdAt)
|
||||
}
|
||||
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PatternFormat } from 'react-number-format'
|
||||
import { Link, useOutletContext } from 'react-router'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { User } from '@/routes/_.$orgid.users.$id/route'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -27,13 +24,8 @@ import { Input } from '@repo/ui/components/ui/input'
|
||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().nonempty('Digite seu nome'),
|
||||
email: z.email(),
|
||||
cpf: z
|
||||
.string('CPF obrigatório')
|
||||
.refine(isValidCPF, { message: 'CPF inválido' })
|
||||
})
|
||||
import type { User } from '../_.$orgid.users.$id/route'
|
||||
import { formSchema, type Schema } from '../_.$orgid.users.add/route'
|
||||
|
||||
export default function Route() {
|
||||
const { user } = useOutletContext() as { user: User }
|
||||
@@ -43,7 +35,7 @@ export default function Route() {
|
||||
})
|
||||
const { handleSubmit, control, formState } = form
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||
const onSubmit = async (data: Schema) => {
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
@@ -133,7 +125,7 @@ export default function Route() {
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting && <Spinner />}
|
||||
Editar colaborador
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { Await } from 'react-router'
|
||||
import { Await, useOutletContext } from 'react-router'
|
||||
|
||||
import { request as req } from '@/lib/request'
|
||||
|
||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@repo/ui/components/ui/card'
|
||||
import { Item, ItemContent, ItemGroup } from '@repo/ui/components/ui/item'
|
||||
import { Kbd } from '@repo/ui/components/ui/kbd'
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOption
|
||||
} from '@repo/ui/components/ui/native-select'
|
||||
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import type { User } from '../_.$orgid.users.$id/route'
|
||||
|
||||
export async function loader({ params, request, context }: Route.LoaderArgs) {
|
||||
const { id } = params
|
||||
@@ -18,16 +35,58 @@ export async function loader({ params, request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export default function Route({ loaderData: { data } }) {
|
||||
const { user } = useOutletContext() as { user: User }
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<Await resolve={data}>
|
||||
{({ items = [] }) => (
|
||||
<ul>
|
||||
{items.map(({ sk }: { sk: string }, idx: number) => {
|
||||
const [, email] = sk.split('#')
|
||||
return <li key={idx}>{email}</li>
|
||||
})}
|
||||
</ul>
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Emails</CardTitle>
|
||||
<CardDescription>
|
||||
Podem ser associados vários emails a uma conta. É possível
|
||||
usar qualquer email para recuperar a senha, mas apenas o email
|
||||
principal receberá as mensagens.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul>
|
||||
{items.map(({ sk }: { sk: string }, idx: number) => {
|
||||
const [, email] = sk.split('#')
|
||||
return <li key={idx}>{email}</li>
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Email principal</CardTitle>
|
||||
<CardDescription>
|
||||
<Kbd className="font-mono border">{user.email}</Kbd> será
|
||||
usado para mensagens e pode ser usado para redefinições de
|
||||
senha.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex gap-1.5">
|
||||
<NativeSelect value={user.email}>
|
||||
{items.map(({ sk }: { sk: string }, idx: number) => {
|
||||
const [, email] = sk.split('#')
|
||||
return (
|
||||
<NativeSelectOption key={idx} value={email}>
|
||||
{email}
|
||||
</NativeSelectOption>
|
||||
)
|
||||
})}
|
||||
</NativeSelect>
|
||||
<Button>Mudar</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Await } from 'react-router'
|
||||
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { request as req } from '@/lib/request'
|
||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export async function loader({ params, request, context }: Route.LoaderArgs) {
|
||||
const { id } = params
|
||||
const r = req({
|
||||
url: `/users/${id}/orgs`,
|
||||
request,
|
||||
context
|
||||
}).then((r) => r.json())
|
||||
|
||||
return { data: r }
|
||||
}
|
||||
|
||||
export default function Route({ loaderData: { data } }) {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<Await resolve={data}>
|
||||
{({ items = [] }) => (
|
||||
<ul>
|
||||
{items.map(
|
||||
({ name, cnpj }: { name: string; cnpj: string }, idx: number) => {
|
||||
return (
|
||||
<li key={idx}>
|
||||
{name} {cnpj}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'react-router'
|
||||
|
||||
import { request as req } from '@/lib/request'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -90,7 +91,7 @@ export default function Route({
|
||||
</Avatar>
|
||||
|
||||
<ul>
|
||||
<li className="font-bold">{user.name}</li>
|
||||
<li className="font-bold text-lg">{user.name}</li>
|
||||
<li className="text-muted-foreground text-sm">{user.email}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,23 @@
|
||||
|
||||
import { formatCPF } from '@brazilian-utils/brazilian-utils'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import {
|
||||
ArrowRight,
|
||||
EllipsisVerticalIcon,
|
||||
PencilIcon,
|
||||
UserRoundMinusIcon
|
||||
} from 'lucide-react'
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
import { Abbr } from '@/components/abbr'
|
||||
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@repo/ui/components/ui/dropdown-menu'
|
||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||
import { initials } from '@repo/ui/lib/utils'
|
||||
|
||||
@@ -65,13 +76,6 @@ export const columns: ColumnDef<User>[] = [
|
||||
return <></>
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Cadastrado em',
|
||||
cell: ({ row }) => {
|
||||
const created_at = new Date(row.original.createDate)
|
||||
return formatted.format(created_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Último accesso',
|
||||
cell: ({ row }) => {
|
||||
@@ -85,30 +89,47 @@ export const columns: ColumnDef<User>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
header: ' ',
|
||||
header: 'Cadastrado em',
|
||||
meta: {
|
||||
className: 'w-1/12'
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative group"
|
||||
asChild
|
||||
>
|
||||
<NavLink to={`${row.original?.id}`}>
|
||||
{({ isPending }) => (
|
||||
<>
|
||||
{isPending && <Spinner className="absolute" />}
|
||||
<span className="group-[.pending]:invisible">
|
||||
Editar
|
||||
</span>{' '}
|
||||
<ArrowRight className="group-[.pending]:invisible" />
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
const created_at = new Date(row.original.createDate)
|
||||
return formatted.format(created_at)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
|
||||
size="icon-sm"
|
||||
>
|
||||
<EllipsisVerticalIcon />
|
||||
<span className="sr-only">Abrir menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36 *:cursor-pointer">
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<NavLink to={`${row.id}`}>
|
||||
{({ isPending }) => (
|
||||
<>
|
||||
{isPending ? <Spinner /> : <PencilIcon />}
|
||||
Editar
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<UserRoundMinusIcon /> Desvincular
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function Route({ loaderData: { data } }) {
|
||||
{({ hits, page, hitsPerPage, totalHits }) => {
|
||||
return (
|
||||
<DataTable
|
||||
sort={[{ id: 'created_at', desc: true }]}
|
||||
columns={columns}
|
||||
data={hits as User[]}
|
||||
pageIndex={page - 1}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Link } from 'react-router'
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { PatternFormat } from 'react-number-format'
|
||||
import { Link, useFetcher } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -8,6 +16,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from '@repo/ui/components/ui/breadcrumb'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -15,8 +24,91 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@repo/ui/components/ui/card'
|
||||
import { Checkbox } from '@repo/ui/components/ui/checkbox'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@repo/ui/components/ui/form'
|
||||
import { Input } from '@repo/ui/components/ui/input'
|
||||
import { Label } from '@repo/ui/components/ui/label'
|
||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||
|
||||
import { useWorksapce } from '@/components/workspace-switcher'
|
||||
import { HttpMethod, request as req } from '@/lib/request'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const isName = (name: string) => name && name.includes(' ')
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.nonempty('Digite um nome')
|
||||
.refine(isName, { message: 'Nome inválido' }),
|
||||
email: z.email('Email inválido').trim().toLowerCase(),
|
||||
cpf: z
|
||||
.string('CPF obrigatório')
|
||||
.refine(isValidCPF, { message: 'CPF inválido' })
|
||||
})
|
||||
|
||||
export type Schema = z.infer<typeof formSchema>
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Adicionar colaborador' }]
|
||||
}
|
||||
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
const body = await request.json()
|
||||
const r = await req({
|
||||
url: `users`,
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
method: HttpMethod.POST,
|
||||
body: JSON.stringify(body),
|
||||
request,
|
||||
context
|
||||
})
|
||||
|
||||
console.log(r)
|
||||
|
||||
if (!r.ok) {
|
||||
const error = await r.json().catch(() => ({}))
|
||||
return { ok: false, error }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export default function Route() {
|
||||
const fetcher = useFetcher()
|
||||
const { activeWorkspace } = useWorksapce()
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema)
|
||||
})
|
||||
const { handleSubmit, control, formState, reset } = form
|
||||
|
||||
const onSubmit = async (user: Schema) => {
|
||||
await fetcher.submit(JSON.stringify({ user, org: activeWorkspace }), {
|
||||
method: 'post',
|
||||
encType: 'application/json'
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.ok) {
|
||||
toast.success('O colaborador foi adicionado')
|
||||
return reset()
|
||||
}
|
||||
|
||||
switch (fetcher.data?.error?.type) {
|
||||
case 'UserConflictError':
|
||||
toast.error('O colaborador já foi vinculado anteriormente')
|
||||
}
|
||||
}, [fetcher.data])
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<Breadcrumb>
|
||||
@@ -33,17 +125,97 @@ export default function Route() {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="lg:max-w-2xl mx-auto space-y-2.5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Adicionar colaborador</CardTitle>
|
||||
<CardDescription>
|
||||
Siga os passos abaixo para cadastrar um novo colaborador
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div className="lg:max-w-2xl mx-auto">
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">
|
||||
Adicionar colaborador
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Siga os passos abaixo para cadastrar um novo colaborador
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent></CardContent>
|
||||
</Card>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nome</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<FormField
|
||||
control={control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="terms" tabIndex={-1} />
|
||||
<Label htmlFor="terms">
|
||||
Usar um email fornecido pela plataforma.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="cpf"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ref, ...props } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CPF</FormLabel>
|
||||
<FormControl>
|
||||
<PatternFormat
|
||||
format="###.###.###-##"
|
||||
mask="_"
|
||||
placeholder="___.___.___-__"
|
||||
customInput={Input}
|
||||
getInputRef={ref}
|
||||
onValueChange={({ value }) => {
|
||||
onChange(value)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="cursor-pointer"
|
||||
disabled={formState.isSubmitting}
|
||||
>
|
||||
{formState.isSubmitting && <Spinner />}
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { request as req } from '@/lib/request'
|
||||
|
||||
import { WorkspaceProvider } from '@/components/workspace-switcher'
|
||||
import { userContext } from '@repo/auth/context'
|
||||
import { authMiddleware } from '@repo/auth/middleware/auth'
|
||||
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger
|
||||
} from '@repo/ui/components/ui/sidebar'
|
||||
import { Toaster } from '@repo/ui/components/ui/sonner'
|
||||
|
||||
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
||||
|
||||
@@ -59,31 +61,34 @@ export default function Route({ loaderData }: Route.ComponentProps) {
|
||||
const { user, orgs, sidebar_state } = loaderData
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex">
|
||||
<AppSidebar orgs={orgs} />
|
||||
<WorkspaceProvider workspaces={orgs}>
|
||||
<SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex">
|
||||
<AppSidebar />
|
||||
|
||||
<SidebarInset className="relative flex flex-col flex-1 min-w-0">
|
||||
<header
|
||||
className="bg-background/15 backdrop-blur-sm
|
||||
px-4 py-2 lg:py-4 sticky top-0 z-5"
|
||||
>
|
||||
<div className="container mx-auto flex items-center">
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
<ThemedImage className="max-md:hidden" />
|
||||
<SidebarInset className="relative flex flex-col flex-1 min-w-0">
|
||||
<header
|
||||
className="bg-background/15 backdrop-blur-sm
|
||||
px-4 py-2 lg:py-4 sticky top-0 z-5"
|
||||
>
|
||||
<div className="container mx-auto flex items-center">
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
<ThemedImage className="max-md:hidden" />
|
||||
|
||||
<div className="ml-auto flex gap-2.5 items-center">
|
||||
<ModeToggle />
|
||||
<NavUser user={user} />
|
||||
<div className="ml-auto flex gap-2.5 items-center">
|
||||
<ModeToggle />
|
||||
<NavUser user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<main className="p-4">
|
||||
<div className="container mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<main className="p-4">
|
||||
<div className="container mx-auto relative">
|
||||
<Outlet />
|
||||
<Toaster position="top-center" richColors={true} />
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</WorkspaceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user