add user
This commit is contained in:
@@ -46,6 +46,11 @@ app.include_router(orders.router, prefix='/orders')
|
|||||||
app.include_router(orgs.custom_pricing, prefix='/orgs')
|
app.include_router(orgs.custom_pricing, prefix='/orgs')
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/health')
|
||||||
|
def health():
|
||||||
|
return {'status': 'available'}
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(ServiceError)
|
@app.exception_handler(ServiceError)
|
||||||
def exc_error(exc: ServiceError):
|
def exc_error(exc: ServiceError):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
45
api.saladeaula.digital/app/keep_warm.py
Normal file
45
api.saladeaula.digital/app/keep_warm.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from aws_lambda_powertools import Logger, Tracer
|
||||||
|
from aws_lambda_powertools.utilities.data_classes import (
|
||||||
|
EventBridgeEvent,
|
||||||
|
event_source,
|
||||||
|
)
|
||||||
|
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||||
|
|
||||||
|
logger = Logger(__name__)
|
||||||
|
tracer = Tracer()
|
||||||
|
urls = ['https://bcs7fgb9og.execute-api.sa-east-1.amazonaws.com/health']
|
||||||
|
|
||||||
|
|
||||||
|
@tracer.capture_lambda_handler
|
||||||
|
@event_source(data_class=EventBridgeEvent)
|
||||||
|
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
|
futures = [executor.submit(ping, url) for url in urls]
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
results.append(future.result())
|
||||||
|
|
||||||
|
logger.info(results)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ping(url: str):
|
||||||
|
try:
|
||||||
|
r = requests.get(url, timeout=4)
|
||||||
|
r.raise_for_status()
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
return {
|
||||||
|
'url': url,
|
||||||
|
'error': str(exc),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'url': url,
|
||||||
|
'status': r.status_code,
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
AWSTemplateFormatVersion: "2010-09-09"
|
AWSTemplateFormatVersion: '2010-09-09'
|
||||||
Transform: "AWS::Serverless-2016-10-31"
|
Transform: 'AWS::Serverless-2016-10-31'
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
UserTable:
|
UserTable:
|
||||||
@@ -46,11 +46,16 @@ Resources:
|
|||||||
Properties:
|
Properties:
|
||||||
RetentionInDays: 90
|
RetentionInDays: 90
|
||||||
|
|
||||||
|
ScheduleLog:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
RetentionInDays: 7
|
||||||
|
|
||||||
HttpApi:
|
HttpApi:
|
||||||
Type: AWS::Serverless::HttpApi
|
Type: AWS::Serverless::HttpApi
|
||||||
Properties:
|
Properties:
|
||||||
CorsConfiguration:
|
CorsConfiguration:
|
||||||
AllowOrigins: ["*"]
|
AllowOrigins: ['*']
|
||||||
AllowMethods: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
|
AllowMethods: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
|
||||||
AllowHeaders: [Content-Type, X-Requested-With, Authorization]
|
AllowHeaders: [Content-Type, X-Requested-With, Authorization]
|
||||||
AllowCredentials: false
|
AllowCredentials: false
|
||||||
@@ -59,13 +64,13 @@ Resources:
|
|||||||
DefaultAuthorizer: OAuth2Authorizer
|
DefaultAuthorizer: OAuth2Authorizer
|
||||||
Authorizers:
|
Authorizers:
|
||||||
OAuth2Authorizer:
|
OAuth2Authorizer:
|
||||||
IdentitySource: "$request.header.Authorization"
|
IdentitySource: '$request.header.Authorization'
|
||||||
JwtConfiguration:
|
JwtConfiguration:
|
||||||
issuer: "https://id.saladeaula.digital"
|
issuer: 'https://id.saladeaula.digital'
|
||||||
audience:
|
audience:
|
||||||
- "1a5483ab-4521-4702-9115-5857ac676851"
|
- '1a5483ab-4521-4702-9115-5857ac676851'
|
||||||
- "1db63660-063d-4280-b2ea-388aca4a9459"
|
- '1db63660-063d-4280-b2ea-388aca4a9459'
|
||||||
- "78a0819e-1f9b-4da1-b05f-40ec0eaed0c8"
|
- '78a0819e-1f9b-4da1-b05f-40ec0eaed0c8'
|
||||||
|
|
||||||
HttpApiFunction:
|
HttpApiFunction:
|
||||||
Type: AWS::Serverless::Function
|
Type: AWS::Serverless::Function
|
||||||
@@ -97,12 +102,33 @@ Resources:
|
|||||||
Path: /{proxy+}
|
Path: /{proxy+}
|
||||||
Method: ANY
|
Method: ANY
|
||||||
ApiId: !Ref HttpApi
|
ApiId: !Ref HttpApi
|
||||||
|
Health:
|
||||||
|
Type: HttpApi
|
||||||
|
Properties:
|
||||||
|
Path: /health
|
||||||
|
Method: GET
|
||||||
|
ApiId: !Ref HttpApi
|
||||||
|
Auth:
|
||||||
|
Authorizer: NONE
|
||||||
|
|
||||||
|
EventKeepWarmScheduledFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
Handler: keep_warm.lambda_handler
|
||||||
|
LoggingConfig:
|
||||||
|
LogGroup: !Ref ScheduleLog
|
||||||
|
Events:
|
||||||
|
ScheduleEvent:
|
||||||
|
Type: ScheduleV2
|
||||||
|
Properties:
|
||||||
|
ScheduleExpression: 'cron(*/3 5-23 * * ? *)'
|
||||||
|
ScheduleExpressionTimezone: America/Sao_Paulo
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
HttpApiUrl:
|
HttpApiUrl:
|
||||||
Description: URL of your API endpoint
|
Description: URL of your API endpoint
|
||||||
Value:
|
Value:
|
||||||
Fn::Sub: "https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}"
|
Fn::Sub: 'https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}'
|
||||||
HttpApiId:
|
HttpApiId:
|
||||||
Description: Api ID of HttpApi
|
Description: Api ID of HttpApi
|
||||||
Value:
|
Value:
|
||||||
|
|||||||
5
api.saladeaula.digital/tests/test_keep_warm.py
Normal file
5
api.saladeaula.digital/tests/test_keep_warm.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import keep_warm
|
||||||
|
|
||||||
|
|
||||||
|
def test_keep_warm():
|
||||||
|
keep_warm.lambda_handler({}, {})
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { NavMain } from '@/components/nav-main'
|
import { NavMain } from '@/components/nav-main'
|
||||||
import { OrgSwitcher } from '@/components/org-switcher'
|
import { WorkspaceSwitcher } from '@/components/workspace-switcher'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -42,11 +42,6 @@ const data = {
|
|||||||
url: '/admins',
|
url: '/admins',
|
||||||
icon: ShieldUserIcon
|
icon: ShieldUserIcon
|
||||||
}
|
}
|
||||||
// {
|
|
||||||
// title: 'Webhooks',
|
|
||||||
// url: '/webhooks',
|
|
||||||
// icon: WebhookIcon
|
|
||||||
// }
|
|
||||||
],
|
],
|
||||||
navContent: [
|
navContent: [
|
||||||
{
|
{
|
||||||
@@ -76,7 +71,7 @@ export function AppSidebar({ orgs = [] }) {
|
|||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<OrgSwitcher orgs={orgs} />
|
<WorkspaceSwitcher />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain data={data} />
|
<NavMain data={data} />
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from '@repo/ui/components/ui/table'
|
} from '@repo/ui/components/ui/table'
|
||||||
|
import { cn } from '@repo/ui/lib/utils'
|
||||||
import { DataTablePagination } from './pagination'
|
import { DataTablePagination } from './pagination'
|
||||||
|
|
||||||
interface DataTableProps<TData, TValue> {
|
interface DataTableProps<TData, TValue> {
|
||||||
@@ -145,7 +146,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<Table_>
|
<Table_ className="table-auto">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -154,7 +155,13 @@ export function DataTable<TData, TValue>({
|
|||||||
>
|
>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
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
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
@@ -176,7 +183,13 @@ export function DataTable<TData, TValue>({
|
|||||||
data-state={row.getIsSelected() && 'selected'}
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{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(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext()
|
cell.getContext()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
|
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
|
||||||
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
|
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { createContext, useContext, useState } from 'react'
|
||||||
import { useLocation, useParams } from 'react-router'
|
import { useLocation, useParams } from 'react-router'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -23,23 +23,60 @@ import {
|
|||||||
} from '@repo/ui/components/ui/sidebar'
|
} from '@repo/ui/components/ui/sidebar'
|
||||||
import { initials } from '@repo/ui/lib/utils'
|
import { initials } from '@repo/ui/lib/utils'
|
||||||
|
|
||||||
type Org = {
|
type Workspace = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
cnpj: 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 location = useLocation()
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar()
|
||||||
const { orgid } = useParams()
|
const { activeWorkspace, setActiveWorkspace, workspaces } = useWorksapce()
|
||||||
const org = orgs.find((org) => org.id === orgid) as Org
|
|
||||||
const [activeOrg, setActiveOrg] = useState<Org>(org)
|
|
||||||
const [, fragment, _] = location.pathname.slice(1).split('/')
|
const [, fragment, _] = location.pathname.slice(1).split('/')
|
||||||
|
|
||||||
const onSelect = (org: Org) => {
|
const onSelect = (ws: Workspace) => {
|
||||||
setActiveOrg(org)
|
setActiveWorkspace(ws)
|
||||||
window.location.assign(`/${org.id}/${fragment}`)
|
window.location.assign(`/${ws.id}/${fragment}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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"
|
className="aria-expanded:border flex aspect-square size-8 items-center justify-center rounded-lg"
|
||||||
aria-expanded={state === 'expanded'}
|
aria-expanded={state === 'expanded'}
|
||||||
>
|
>
|
||||||
{initials(activeOrg?.name)}
|
{initials(activeWorkspace?.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<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">
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
{formatCNPJ(activeOrg?.cnpj)}
|
{formatCNPJ(activeWorkspace?.cnpj)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDownIcon className="ml-auto" />
|
<ChevronsUpDownIcon className="ml-auto" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||||
align="start"
|
align="start"
|
||||||
@@ -75,20 +115,23 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
|||||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||||
Empresas
|
Empresas
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{orgs.map((org, index) => (
|
|
||||||
|
{workspaces.map((workspace, index) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => onSelect(org)}
|
onClick={() => onSelect(workspace)}
|
||||||
className="group gap-2 p-2 cursor-pointer aria-selected:pointer-events-none"
|
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">
|
<div className="flex size-8 items-center justify-center rounded-lg border">
|
||||||
{initials(org?.name)}
|
{initials(workspace?.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<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">
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
{formatCNPJ(org?.cnpj)}
|
{formatCNPJ(workspace?.cnpj)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuShortcut className="not-group-aria-selected:hidden">
|
<DropdownMenuShortcut className="not-group-aria-selected:hidden">
|
||||||
@@ -96,7 +139,9 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
|
|||||||
</DropdownMenuShortcut>
|
</DropdownMenuShortcut>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem className="gap-2 p-2">
|
<DropdownMenuItem className="gap-2 p-2">
|
||||||
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
@@ -43,4 +43,4 @@ export default async function handleRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// https://reactrouter.com/how-to/suspense#timeouts
|
// 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 requestId = context.get(requestIdContext) as string
|
||||||
const user = context.get(userContext) as User
|
const user = context.get(userContext) as User
|
||||||
const url_ = new URL(url, context.cloudflare.env.API_URL)
|
const url_ = new URL(url, context.cloudflare.env.API_URL)
|
||||||
const headers = new Headers(
|
const headers = new Headers({
|
||||||
Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _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(
|
console.log(
|
||||||
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`
|
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`
|
||||||
|
|||||||
@@ -119,9 +119,6 @@ export default function Route({ loaderData: { data } }) {
|
|||||||
{selectedRows.length ? (
|
{selectedRows.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2.5 items-center">
|
<div className="flex gap-2.5 items-center">
|
||||||
<Button variant="outline">
|
|
||||||
<TagIcon /> Marcador
|
|
||||||
</Button>
|
|
||||||
<DropdownMenuExport
|
<DropdownMenuExport
|
||||||
headers={headers}
|
headers={headers}
|
||||||
selectedRows={selectedRows}
|
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 }) => {
|
cell: ({ row }) => {
|
||||||
try {
|
try {
|
||||||
const dueDate = new Date(row.original.due_date)
|
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 }) => {
|
cell: ({ row }) => {
|
||||||
const createdAt = new Date(row.original.create_date)
|
if (row.original.payment_date) {
|
||||||
|
const createdAt = new Date(row.original.payment_date)
|
||||||
return formatted.format(createdAt)
|
return formatted.format(createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import type { Route } from './+types'
|
import type { Route } from './+types'
|
||||||
|
|
||||||
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { PatternFormat } from 'react-number-format'
|
import { PatternFormat } from 'react-number-format'
|
||||||
import { Link, useOutletContext } from 'react-router'
|
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 { Button } from '@repo/ui/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -27,13 +24,8 @@ import { Input } from '@repo/ui/components/ui/input'
|
|||||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
|
|
||||||
const formSchema = z.object({
|
import type { User } from '../_.$orgid.users.$id/route'
|
||||||
name: z.string().trim().nonempty('Digite seu nome'),
|
import { formSchema, type Schema } from '../_.$orgid.users.add/route'
|
||||||
email: z.email(),
|
|
||||||
cpf: z
|
|
||||||
.string('CPF obrigatório')
|
|
||||||
.refine(isValidCPF, { message: 'CPF inválido' })
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function Route() {
|
export default function Route() {
|
||||||
const { user } = useOutletContext() as { user: User }
|
const { user } = useOutletContext() as { user: User }
|
||||||
@@ -43,7 +35,7 @@ export default function Route() {
|
|||||||
})
|
})
|
||||||
const { handleSubmit, control, formState } = form
|
const { handleSubmit, control, formState } = form
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
const onSubmit = async (data: Schema) => {
|
||||||
console.log(data)
|
console.log(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +125,7 @@ export default function Route() {
|
|||||||
disabled={formState.isSubmitting}
|
disabled={formState.isSubmitting}
|
||||||
>
|
>
|
||||||
{formState.isSubmitting && <Spinner />}
|
{formState.isSubmitting && <Spinner />}
|
||||||
Editar colaborador
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import type { Route } from './+types'
|
import type { Route } from './+types'
|
||||||
|
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import { Await } from 'react-router'
|
import { Await, useOutletContext } from 'react-router'
|
||||||
|
|
||||||
import { request as req } from '@/lib/request'
|
import { request as req } from '@/lib/request'
|
||||||
|
|
||||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
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) {
|
export async function loader({ params, request, context }: Route.LoaderArgs) {
|
||||||
const { id } = params
|
const { id } = params
|
||||||
@@ -18,16 +35,58 @@ export async function loader({ params, request, context }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Route({ loaderData: { data } }) {
|
export default function Route({ loaderData: { data } }) {
|
||||||
|
const { user } = useOutletContext() as { user: User }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Skeleton />}>
|
<Suspense fallback={<Skeleton />}>
|
||||||
<Await resolve={data}>
|
<Await resolve={data}>
|
||||||
{({ items = [] }) => (
|
{({ items = [] }) => (
|
||||||
|
<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>
|
<ul>
|
||||||
{items.map(({ sk }: { sk: string }, idx: number) => {
|
{items.map(({ sk }: { sk: string }, idx: number) => {
|
||||||
const [, email] = sk.split('#')
|
const [, email] = sk.split('#')
|
||||||
return <li key={idx}>{email}</li>
|
return <li key={idx}>{email}</li>
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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>
|
</Await>
|
||||||
</Suspense>
|
</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'
|
} from 'react-router'
|
||||||
|
|
||||||
import { request as req } from '@/lib/request'
|
import { request as req } from '@/lib/request'
|
||||||
|
|
||||||
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -90,7 +91,7 @@ export default function Route({
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<ul>
|
<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>
|
<li className="text-muted-foreground text-sm">{user.email}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,23 @@
|
|||||||
|
|
||||||
import { formatCPF } from '@brazilian-utils/brazilian-utils'
|
import { formatCPF } from '@brazilian-utils/brazilian-utils'
|
||||||
import { type ColumnDef } from '@tanstack/react-table'
|
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 { NavLink } from 'react-router'
|
||||||
|
|
||||||
import { Abbr } from '@/components/abbr'
|
import { Abbr } from '@/components/abbr'
|
||||||
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
||||||
import { Button } from '@repo/ui/components/ui/button'
|
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 { Spinner } from '@repo/ui/components/ui/spinner'
|
||||||
import { initials } from '@repo/ui/lib/utils'
|
import { initials } from '@repo/ui/lib/utils'
|
||||||
|
|
||||||
@@ -65,13 +76,6 @@ export const columns: ColumnDef<User>[] = [
|
|||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: 'Cadastrado em',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const created_at = new Date(row.original.createDate)
|
|
||||||
return formatted.format(created_at)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Último accesso',
|
header: 'Último accesso',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -85,30 +89,47 @@ export const columns: ColumnDef<User>[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: ' ',
|
header: 'Cadastrado em',
|
||||||
|
meta: {
|
||||||
|
className: 'w-1/12'
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
const created_at = new Date(row.original.createDate)
|
||||||
<div className="flex justify-end">
|
return formatted.format(created_at)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end items-center">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
|
||||||
className="relative group"
|
size="icon-sm"
|
||||||
asChild
|
|
||||||
>
|
>
|
||||||
<NavLink to={`${row.original?.id}`}>
|
<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 }) => (
|
||||||
<>
|
<>
|
||||||
{isPending && <Spinner className="absolute" />}
|
{isPending ? <Spinner /> : <PencilIcon />}
|
||||||
<span className="group-[.pending]:invisible">
|
|
||||||
Editar
|
Editar
|
||||||
</span>{' '}
|
|
||||||
<ArrowRight className="group-[.pending]:invisible" />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem variant="destructive">
|
||||||
|
<UserRoundMinusIcon /> Desvincular
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default function Route({ loaderData: { data } }) {
|
|||||||
{({ hits, page, hitsPerPage, totalHits }) => {
|
{({ hits, page, hitsPerPage, totalHits }) => {
|
||||||
return (
|
return (
|
||||||
<DataTable
|
<DataTable
|
||||||
|
sort={[{ id: 'created_at', desc: true }]}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={hits as User[]}
|
data={hits as User[]}
|
||||||
pageIndex={page - 1}
|
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 {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -8,6 +16,7 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator
|
BreadcrumbSeparator
|
||||||
} from '@repo/ui/components/ui/breadcrumb'
|
} from '@repo/ui/components/ui/breadcrumb'
|
||||||
|
import { Button } from '@repo/ui/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -15,8 +24,91 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '@repo/ui/components/ui/card'
|
} 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() {
|
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 (
|
return (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
@@ -33,17 +125,97 @@ export default function Route() {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|
||||||
<div className="lg:max-w-2xl mx-auto space-y-2.5">
|
<div className="lg:max-w-2xl mx-auto">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Adicionar colaborador</CardTitle>
|
<CardTitle className="text-2xl">
|
||||||
|
Adicionar colaborador
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Siga os passos abaixo para cadastrar um novo colaborador
|
Siga os passos abaixo para cadastrar um novo colaborador
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent></CardContent>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router'
|
|||||||
import { AppSidebar } from '@/components/app-sidebar'
|
import { AppSidebar } from '@/components/app-sidebar'
|
||||||
import { request as req } from '@/lib/request'
|
import { request as req } from '@/lib/request'
|
||||||
|
|
||||||
|
import { WorkspaceProvider } from '@/components/workspace-switcher'
|
||||||
import { userContext } from '@repo/auth/context'
|
import { userContext } from '@repo/auth/context'
|
||||||
import { authMiddleware } from '@repo/auth/middleware/auth'
|
import { authMiddleware } from '@repo/auth/middleware/auth'
|
||||||
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
|
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarTrigger
|
SidebarTrigger
|
||||||
} from '@repo/ui/components/ui/sidebar'
|
} from '@repo/ui/components/ui/sidebar'
|
||||||
|
import { Toaster } from '@repo/ui/components/ui/sonner'
|
||||||
|
|
||||||
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
||||||
|
|
||||||
@@ -59,8 +61,9 @@ export default function Route({ loaderData }: Route.ComponentProps) {
|
|||||||
const { user, orgs, sidebar_state } = loaderData
|
const { user, orgs, sidebar_state } = loaderData
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<WorkspaceProvider workspaces={orgs}>
|
||||||
<SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex">
|
<SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex">
|
||||||
<AppSidebar orgs={orgs} />
|
<AppSidebar />
|
||||||
|
|
||||||
<SidebarInset className="relative flex flex-col flex-1 min-w-0">
|
<SidebarInset className="relative flex flex-col flex-1 min-w-0">
|
||||||
<header
|
<header
|
||||||
@@ -79,11 +82,13 @@ export default function Route({ loaderData }: Route.ComponentProps) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="p-4">
|
<main className="p-4">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto relative">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<Toaster position="top-center" richColors={true} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
</WorkspaceProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import type { User } from '@/middleware/auth'
|
|
||||||
import { createContext } from 'react-router'
|
|
||||||
|
|
||||||
export const userContext = createContext<User | null>(null)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { LoaderFunctionArgs } from 'react-router'
|
|
||||||
|
|
||||||
import type { User } from '@repo/auth/auth'
|
import type { User } from '@repo/auth/auth'
|
||||||
import { userContext } from '@repo/auth/context'
|
import { requestIdContext, userContext } from '@repo/auth/context'
|
||||||
|
|
||||||
|
import type { LoaderFunctionArgs } from 'react-router'
|
||||||
|
|
||||||
export enum HttpMethod {
|
export enum HttpMethod {
|
||||||
GET = 'GET',
|
GET = 'GET',
|
||||||
@@ -23,23 +23,21 @@ type RequestArgs = {
|
|||||||
export function request({
|
export function request({
|
||||||
url,
|
url,
|
||||||
method = HttpMethod.GET,
|
method = HttpMethod.GET,
|
||||||
headers: _headers = {},
|
|
||||||
body = null,
|
body = null,
|
||||||
|
headers: _headers = {},
|
||||||
request: { signal },
|
request: { signal },
|
||||||
context
|
context
|
||||||
}: RequestArgs): Promise<Response> {
|
}: RequestArgs): Promise<Response> {
|
||||||
|
const requestId = context.get(requestIdContext) as string
|
||||||
const user = context.get(userContext) as User
|
const user = context.get(userContext) as User
|
||||||
// @ts-ignore
|
const url_ = new URL(url, context.cloudflare.env.API_URL)
|
||||||
const headers = new Headers(
|
const headers = new Headers(
|
||||||
Object.assign(_headers, { Authorization: `Bearer ${user.accessToken}` })
|
Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _headers)
|
||||||
)
|
)
|
||||||
// @ts-ignore
|
|
||||||
const endpoint = new URL(url, context.cloudflare.env.API_URL)
|
|
||||||
|
|
||||||
return fetch(endpoint.toString(), {
|
console.log(
|
||||||
method,
|
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`
|
||||||
headers,
|
)
|
||||||
body,
|
|
||||||
signal
|
return fetch(url_.toString(), { method, headers, body, signal })
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type { Route } from './+types/edit'
|
import type { Route } from './+types/edit'
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import {
|
import { FileBadgeIcon, FileCode2Icon, MoreHorizontalIcon } from 'lucide-react'
|
||||||
CircleCheckIcon,
|
|
||||||
FileBadgeIcon,
|
|
||||||
FileCode2Icon,
|
|
||||||
MoreHorizontalIcon
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { Suspense, useState, type ReactNode } from 'react'
|
import { Suspense, useState, type ReactNode } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { Await, useAsyncValue, useFetcher } from 'react-router'
|
import { Await, useAsyncValue, useFetcher } from 'react-router'
|
||||||
@@ -15,15 +10,7 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
import { HttpMethod, request as req } from '@/lib/request'
|
import { HttpMethod, request as req } from '@/lib/request'
|
||||||
|
|
||||||
import type { User } from '@repo/auth/auth'
|
|
||||||
import { userContext } from '@repo/auth/context'
|
|
||||||
|
|
||||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
AlertTitle
|
|
||||||
} from '@repo/ui/components/ui/alert'
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -66,7 +53,7 @@ import {
|
|||||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||||
import { Switch } from '@repo/ui/components/ui/switch'
|
import { Switch } from '@repo/ui/components/ui/switch'
|
||||||
|
|
||||||
const schema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
given_cert: z.coerce.boolean(),
|
given_cert: z.coerce.boolean(),
|
||||||
never_expires: z.coerce.boolean(),
|
never_expires: z.coerce.boolean(),
|
||||||
@@ -94,7 +81,7 @@ const schema = z
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type Schema = z.infer<typeof schema>
|
type Schema = z.infer<typeof formSchema>
|
||||||
|
|
||||||
type Cert = {
|
type Cert = {
|
||||||
exp_interval: number
|
exp_interval: number
|
||||||
@@ -132,16 +119,12 @@ export const loader = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ params, request, context }: Route.ActionArgs) {
|
export async function action({ params, request, context }: Route.ActionArgs) {
|
||||||
const user = context.get(userContext) as User
|
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
|
|
||||||
const r = await req({
|
const r = await req({
|
||||||
url: `courses/${params.id}`,
|
url: `courses/${params.id}`,
|
||||||
method: HttpMethod.PUT,
|
method: HttpMethod.PUT,
|
||||||
body: formData,
|
body: formData,
|
||||||
headers: new Headers({
|
|
||||||
Authorization: `Bearer ${user.accessToken}`
|
|
||||||
}),
|
|
||||||
request,
|
request,
|
||||||
context
|
context
|
||||||
})
|
})
|
||||||
@@ -180,7 +163,7 @@ function Editing() {
|
|||||||
const fetcher = useFetcher()
|
const fetcher = useFetcher()
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
draft: false,
|
draft: false,
|
||||||
given_cert: !!course?.cert,
|
given_cert: !!course?.cert,
|
||||||
@@ -415,7 +398,7 @@ function Editing() {
|
|||||||
disabled={formState.isSubmitting}
|
disabled={formState.isSubmitting}
|
||||||
>
|
>
|
||||||
{formState.isSubmitting && <Spinner />}
|
{formState.isSubmitting && <Spinner />}
|
||||||
Editar curso
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
|||||||
from boto3clients import dynamodb_client
|
from boto3clients import dynamodb_client
|
||||||
from config import API_URL, COURSE_TABLE
|
from config import API_URL, COURSE_TABLE
|
||||||
|
|
||||||
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
|
dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
|
||||||
|
|
||||||
|
|
||||||
@event_source(data_class=DynamoDBStreamEvent)
|
@event_source(data_class=DynamoDBStreamEvent)
|
||||||
@@ -21,7 +21,7 @@ def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext) -> bool:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
now_ = now()
|
now_ = now()
|
||||||
|
|
||||||
with course_layer.transact_writer() as transact:
|
with dyn.transact_writer() as transact:
|
||||||
for course in data:
|
for course in data:
|
||||||
transact.update(
|
transact.update(
|
||||||
key=KeyPair(course['id'], '0'),
|
key=KeyPair(course['id'], '0'),
|
||||||
@@ -14,7 +14,7 @@ Globals:
|
|||||||
Architectures:
|
Architectures:
|
||||||
- x86_64
|
- x86_64
|
||||||
Layers:
|
Layers:
|
||||||
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:83
|
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:100
|
||||||
Environment:
|
Environment:
|
||||||
Variables:
|
Variables:
|
||||||
TZ: America/Sao_Paulo
|
TZ: America/Sao_Paulo
|
||||||
@@ -28,19 +28,19 @@ Resources:
|
|||||||
EventLog:
|
EventLog:
|
||||||
Type: AWS::Logs::LogGroup
|
Type: AWS::Logs::LogGroup
|
||||||
Properties:
|
Properties:
|
||||||
RetentionInDays: 90
|
RetentionInDays: 7
|
||||||
|
|
||||||
EventDailySyncCourseMetadataFunction:
|
EventCopyCourseMetadataScheduledFunction:
|
||||||
Type: AWS::Serverless::Function
|
Type: AWS::Serverless::Function
|
||||||
Properties:
|
Properties:
|
||||||
Handler: events.daily_sync_course_metadata.lambda_handler
|
Handler: events.copy_course_metadata.lambda_handler
|
||||||
LoggingConfig:
|
LoggingConfig:
|
||||||
LogGroup: !Ref EventLog
|
LogGroup: !Ref EventLog
|
||||||
Policies:
|
Policies:
|
||||||
- DynamoDBWritePolicy:
|
- DynamoDBWritePolicy:
|
||||||
TableName: !Ref CourseTable
|
TableName: !Ref CourseTable
|
||||||
Events:
|
Events:
|
||||||
Rule:
|
ScheduleEvent:
|
||||||
Type: ScheduleV2
|
Type: ScheduleV2
|
||||||
Properties:
|
Properties:
|
||||||
ScheduleExpression: cron(0 0 * * ? *)
|
ScheduleExpression: cron(0 0 * * ? *)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from aws_lambda_powertools.utilities.typing import LambdaContext
|
|||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||||
|
|
||||||
|
|
||||||
def test_daily_sync_course_metadata(
|
def test_copy_course_metadata(
|
||||||
dynamodb_client,
|
dynamodb_client,
|
||||||
dynamodb_seeds,
|
dynamodb_seeds,
|
||||||
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
|
||||||
@@ -20,5 +20,7 @@ export const loggingMiddleware = async (
|
|||||||
`[${new Date().toISOString()}] [${requestId}] Response ${response.status} (${duration}ms)`
|
`[${new Date().toISOString()}] [${requestId}] Response ${response.status} (${duration}ms)`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
response.headers.set('Server-Timing', `page;req=${requestId};dur=${duration}`)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function ModeToggle() {
|
|||||||
|
|
||||||
type ThemedImageProps = {
|
type ThemedImageProps = {
|
||||||
children?: string
|
children?: string
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemedImage({ children, ...props }: ThemedImageProps) {
|
export function ThemedImage({ children, ...props }: ThemedImageProps) {
|
||||||
|
|||||||
193
packages/ui/src/components/ui/item.tsx
Normal file
193
packages/ui/src/components/ui/item.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
className={cn("group/item-group flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
className={cn("my-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border-border",
|
||||||
|
muted: "bg-muted/50",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "p-4 gap-4 ",
|
||||||
|
sm: "py-3 px-4 gap-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "div"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="item"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(itemVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ItemMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-actions"
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
}
|
||||||
48
packages/ui/src/components/ui/native-select.tsx
Normal file
48
packages/ui/src/components/ui/native-select.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function NativeSelect({ className, ...props }: React.ComponentProps<"select">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group/native-select relative w-fit has-[select:disabled]:opacity-50"
|
||||||
|
data-slot="native-select-wrapper"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
data-slot="native-select"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="native-select-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOption({ ...props }: React.ComponentProps<"option">) {
|
||||||
|
return <option data-slot="native-select-option" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOptGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"optgroup">) {
|
||||||
|
return (
|
||||||
|
<optgroup
|
||||||
|
data-slot="native-select-optgroup"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user