add badge

This commit is contained in:
2026-01-02 15:32:04 -03:00
parent 1abfd0e93e
commit 57b23d7cd2
9 changed files with 146 additions and 69 deletions

View File

@@ -1,9 +1,15 @@
'use client' 'use client'
import { formatCNPJ } from '@brazilian-utils/brazilian-utils' import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react' import {
import { createContext, useContext, useState } from 'react' BadgeCheckIcon,
import { useLocation, useParams } from 'react-router' BadgeIcon,
CheckIcon,
ChevronsUpDownIcon,
PlusIcon
} from 'lucide-react'
import { createContext, use } from 'react'
import { useLocation } from 'react-router'
import { import {
DropdownMenu, DropdownMenu,
@@ -23,22 +29,22 @@ import {
import { initials } from '@repo/ui/lib/utils' import { initials } from '@repo/ui/lib/utils'
import { Link } from 'react-router' import { Link } from 'react-router'
export type Workspace = { import type { Workspace, WorkspaceContextProps } from '@/middleware/workspace'
id: string
name: string type Subscription = {
cnpj: string billing_day: number
payment_method: 'PIX' | 'BANK_SLIP' | 'MANUAL'
} }
type WorkspaceContextProps = { const WorkspaceContext = createContext<
workspaces: Workspace[] | (WorkspaceContextProps & {
activeWorkspace: Workspace subscription: Subscription | null
setActiveWorkspace: React.Dispatch<React.SetStateAction<Workspace | null>> })
} | null
>(null)
const WorkspaceContext = createContext<WorkspaceContextProps | null>(null)
export function useWorksapce() { export function useWorksapce() {
const ctx = useContext(WorkspaceContext) const ctx = use(WorkspaceContext)
if (!ctx) { if (!ctx) {
throw new Error('WorkspaceContext is null') throw new Error('WorkspaceContext is null')
@@ -48,20 +54,26 @@ export function useWorksapce() {
} }
export function WorkspaceProvider({ export function WorkspaceProvider({
activeWorkspace,
workspaces, workspaces,
subscription,
children children
}: { }: {
activeWorkspace: Workspace
workspaces: Workspace[] workspaces: Workspace[]
subscription?: Subscription
children: React.ReactNode children: React.ReactNode
}) { }) {
const { orgid } = useParams()
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | any>(
() => workspaces.find(({ id }) => id === orgid) || {}
)
return ( return (
<WorkspaceContext <WorkspaceContext
value={{ workspaces, activeWorkspace, setActiveWorkspace }} value={{
activeWorkspace,
workspaces,
subscription:
subscription && Object.keys(subscription).length > 0
? subscription
: null
}}
> >
{children} {children}
</WorkspaceContext> </WorkspaceContext>
@@ -71,11 +83,10 @@ export function WorkspaceProvider({
export function WorkspaceSwitcher() { export function WorkspaceSwitcher() {
const location = useLocation() const location = useLocation()
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar()
const { activeWorkspace, setActiveWorkspace, workspaces } = useWorksapce() const { activeWorkspace, workspaces, subscription } = useWorksapce()
const [, fragment, _] = location.pathname.slice(1).split('/') const [, fragment, _] = location.pathname.slice(1).split('/')
const onSelect = (workspace: Workspace) => { const onSelect = (workspace: Workspace) => {
setActiveWorkspace(workspace)
window.location.assign(`/${workspace.id}/${fragment}`) window.location.assign(`/${workspace.id}/${fragment}`)
} }
@@ -89,17 +100,20 @@ export function WorkspaceSwitcher() {
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground bg-secondary hover:bg-secondary/80 border cursor-pointer" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground bg-secondary hover:bg-secondary/80 border cursor-pointer"
> >
<div <div
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 relative"
aria-expanded={state === 'expanded'} aria-expanded={state === 'expanded'}
> >
{initials(activeWorkspace?.name)} {subscription && (
<BadgeCheckIcon className="fill-blue-500 absolute size-3 -top-1 -right-1" />
)}
{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"> <span className="truncate font-medium">
{activeWorkspace?.name} {activeWorkspace.name}
</span> </span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{formatCNPJ(activeWorkspace?.cnpj)} {formatCNPJ(activeWorkspace.cnpj)}
</span> </span>
</div> </div>
<ChevronsUpDownIcon className="ml-auto" /> <ChevronsUpDownIcon className="ml-auto" />

View File

@@ -0,0 +1 @@
export const TZ = 'America/Sao_Paulo'

View File

@@ -0,0 +1,49 @@
import { type LoaderFunctionArgs, createContext } from 'react-router'
import { userContext } from '@repo/auth/context'
import { request as req } from '@repo/util/request'
export type Workspace = {
id: string
name: string
cnpj: string
}
export type WorkspaceContextProps = {
activeWorkspace: Workspace
workspaces: Workspace[]
}
export const workspaceContext = createContext<WorkspaceContextProps>()
export const workspaceMiddleware = async (
{ params, request, context }: LoaderFunctionArgs,
next: () => Promise<Response>
): Promise<Response> => {
const org_id = params.orgid
const user = context.get(userContext)!
const r = await req({
url: `/users/${user.sub}/orgs?limit=25`,
request,
context
})
if (!r.ok) {
throw new Response(await r.text(), { status: r.status })
}
const { items } = (await r.json()) as { items: { sk: string }[] }
const workspaces = items.map(({ sk, ...props }) => {
const [, id] = sk?.split('#')
return { ...props, id }
}) as Workspace[]
const activeWorkspace = workspaces.find(
({ id }) => id === org_id
) as Workspace
context.set(workspaceContext, { activeWorkspace, workspaces })
return await next()
}

View File

@@ -19,5 +19,3 @@ export const labels: Record<string, string> = {
PENDING: 'Em aberto', PENDING: 'Em aberto',
CLOSED: 'Fechado' CLOSED: 'Fechado'
} }
export const tz = 'America/Sao_Paulo'

View File

@@ -3,13 +3,13 @@ import { ChevronRightIcon, ChevronLeftIcon } from 'lucide-react'
import { subMonths, addMonths } from 'date-fns' import { subMonths, addMonths } from 'date-fns'
import { DateTime as LuxonDateTime } from 'luxon' import { DateTime as LuxonDateTime } from 'luxon'
import { TZ } from '@/conf'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { ButtonGroup } from '@repo/ui/components/ui/button-group' import { ButtonGroup } from '@repo/ui/components/ui/button-group'
import { Badge } from '@repo/ui/components/ui/badge' import { Badge } from '@repo/ui/components/ui/badge'
import { DateTime } from '@repo/ui/components/datetime' import { DateTime } from '@repo/ui/components/datetime'
import { formatDate, billingPeriod } from './util' import { formatDate, billingPeriod } from './util'
import { tz } from './data'
type RangePeriodProps = { type RangePeriodProps = {
startDate: Date startDate: Date
@@ -28,7 +28,7 @@ export function RangePeriod({
endDate, endDate,
billingDay billingDay
}: RangePeriodProps) { }: RangePeriodProps) {
const today = LuxonDateTime.now().setZone(tz).toJSDate() const today = LuxonDateTime.now().setZone(TZ).toJSDate()
const [, setSearchParams] = useSearchParams() const [, setSearchParams] = useSearchParams()
const prevPeriod = billingPeriod(billingDay, subMonths(startDate, 1)) const prevPeriod = billingPeriod(billingDay, subMonths(startDate, 1))
const nextPeriod = billingPeriod(billingDay, addMonths(startDate, 1)) const nextPeriod = billingPeriod(billingDay, addMonths(startDate, 1))

View File

@@ -33,9 +33,10 @@ import { Kbd } from '@repo/ui/components/ui/kbd'
import { Currency } from '@repo/ui/components/currency' import { Currency } from '@repo/ui/components/currency'
import { DateTime } from '@repo/ui/components/datetime' import { DateTime } from '@repo/ui/components/datetime'
import { TZ } from '@/conf'
import { billingPeriod, formatDate } from './util' import { billingPeriod, formatDate } from './util'
import { RangePeriod } from './range-period' import { RangePeriod } from './range-period'
import { tz, statuses } from './data' import { statuses } from './data'
export function meta({}) { export function meta({}) {
return [{ title: 'Resumo de cobranças' }] return [{ title: 'Resumo de cobranças' }]
@@ -55,7 +56,7 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
const [startDate, endDate] = billingPeriod( const [startDate, endDate] = billingPeriod(
billing_day, billing_day,
LuxonDateTime.now().setZone(tz).toJSDate() LuxonDateTime.now().setZone(TZ).toJSDate()
) )
const start = searchParams.get('start') || formatDate(startDate) const start = searchParams.get('start') || formatDate(startDate)
const end = searchParams.get('end') || formatDate(endDate) const end = searchParams.get('end') || formatDate(endDate)
@@ -69,8 +70,8 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
return { return {
billing_day, billing_day,
billing, billing,
startDate: LuxonDateTime.fromISO(start, { zone: tz }).toJSDate(), startDate: LuxonDateTime.fromISO(start, { zone: TZ }).toJSDate(),
endDate: LuxonDateTime.fromISO(end, { zone: tz }).toJSDate() endDate: LuxonDateTime.fromISO(end, { zone: TZ }).toJSDate()
} }
} }

View File

@@ -19,6 +19,7 @@ import { cloudflareContext } from '@repo/auth/context'
import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data' import { headers, sortings, statuses } from '@repo/ui/routes/enrollments/data'
import { columns, type Enrollment } from './columns' import { columns, type Enrollment } from './columns'
import { useWorksapce } from '@/components/workspace-switcher'
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Matrículas' }] return [{ title: 'Matrículas' }]
@@ -66,6 +67,7 @@ export default function Route({
loaderData: { enrollments } loaderData: { enrollments }
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { orgid } = useParams() const { orgid } = useParams()
const { subscription } = useWorksapce()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [selectedRows, setSelectedRows] = useState<Enrollment[]>([]) const [selectedRows, setSelectedRows] = useState<Enrollment[]>([])
const status = searchParams.get('status') const status = searchParams.get('status')
@@ -203,7 +205,7 @@ export default function Route({
<DataTableViewOptions className="flex-1" /> <DataTableViewOptions className="flex-1" />
<Button className="flex-1" asChild> <Button className="flex-1" asChild>
<Link to="add"> <Link to={subscription ? 'add' : 'buy'}>
<PlusIcon /> Adicionar <PlusIcon /> Adicionar
</Link> </Link>
</Button> </Button>

View File

@@ -28,6 +28,7 @@ import {
HoverCardTrigger HoverCardTrigger
} from '@repo/ui/components/ui/hover-card' } from '@repo/ui/components/ui/hover-card'
import { TZ } from '@/conf'
import { import {
MAX_ITEMS, MAX_ITEMS,
formSchema, formSchema,
@@ -67,7 +68,7 @@ export function Assigned({ courses }: AssignedProps) {
...e, ...e,
scheduled_for: e.scheduled_for scheduled_for: e.scheduled_for
? DateTime.fromISO(e.scheduled_for, { ? DateTime.fromISO(e.scheduled_for, {
zone: 'America/Sao_Paulo' zone: TZ
}).toJSDate() }).toJSDate()
: undefined : undefined
})) }))

View File

@@ -2,58 +2,58 @@ import type { Route } from './+types/route'
import * as cookie from 'cookie' import * as cookie from 'cookie'
import { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router' import { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router'
import { useEffect } from 'react' import { use, useEffect } from 'react'
import { request as req } from '@repo/util/request'
import {
WorkspaceProvider,
type Workspace
} from '@/components/workspace-switcher'
import { userContext } from '@repo/auth/context'
import { Toaster } from '@repo/ui/components/ui/sonner'
import { authMiddleware } from '@repo/auth/middleware/auth'
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
import { NavUser } from '@repo/ui/components/nav-user'
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
SidebarTrigger SidebarTrigger
} from '@repo/ui/components/ui/sidebar' } from '@repo/ui/components/ui/sidebar'
import { userContext } from '@repo/auth/context'
import { request as req } from '@repo/util/request'
import { authMiddleware } from '@repo/auth/middleware/auth'
import { Toaster } from '@repo/ui/components/ui/sonner'
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
import { NavUser } from '@repo/ui/components/nav-user'
import { WorkspaceProvider } from '@/components/workspace-switcher'
import { AppSidebar } from '@/components/app-sidebar' import { AppSidebar } from '@/components/app-sidebar'
import { workspaceMiddleware, workspaceContext } from '@/middleware/workspace'
// import { Notification } from '@/components/notification' // import { Notification } from '@/components/notification'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware] export const middleware: Route.MiddlewareFunction[] = [
authMiddleware,
workspaceMiddleware
]
export async function loader({ params, context, request }: Route.ActionArgs) { export async function loader({ params, context, request }: Route.ActionArgs) {
const user = context.get(userContext)! const user = context.get(userContext)!
const { activeWorkspace, workspaces } = context.get(workspaceContext)
const rawCookie = request.headers.get('cookie') || '' const rawCookie = request.headers.get('cookie') || ''
const parsedCookies = cookie.parse(rawCookie) const parsedCookies = cookie.parse(rawCookie)
const { sidebar_state = 'true' } = parsedCookies const { sidebar_state = 'true' } = parsedCookies
const r = await req({ const subscription = req({
url: `/users/${user.sub}/orgs?limit=25`, url: `/orgs/${activeWorkspace.id}/subscription`,
request, request,
context context
}) }).then((r) => r.json())
if (!r.ok) { const address = req({
throw new Response(await r.text(), { status: r.status }) url: `/orgs/${activeWorkspace.id}/address`,
request,
context
}).then((r) => r.json())
return {
user,
activeWorkspace,
workspaces,
sidebar_state,
subscription,
address
} }
const { items = [] } = (await r.json()) as { items: { sk: string }[] }
const orgs = items.map(({ sk, ...props }) => {
const [, id] = sk?.split('#')
return { ...props, id }
})
const exists = orgs.some(({ id }) => id === params.orgid)
if (exists) {
return { user, orgs, sidebar_state }
}
throw new Response(null, { status: 401 })
} }
export function shouldRevalidate({ export function shouldRevalidate({
@@ -64,7 +64,14 @@ export function shouldRevalidate({
} }
export default function Route({ loaderData }: Route.ComponentProps) { export default function Route({ loaderData }: Route.ComponentProps) {
const { user, orgs, sidebar_state } = loaderData const {
user,
activeWorkspace,
workspaces,
sidebar_state,
subscription: subscription_
} = loaderData
const subscription = use(subscription_)
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && window.rybbit) { if (typeof window !== 'undefined' && window.rybbit) {
@@ -77,7 +84,11 @@ export default function Route({ loaderData }: Route.ComponentProps) {
}, []) }, [])
return ( return (
<WorkspaceProvider workspaces={orgs as Workspace[]}> <WorkspaceProvider
activeWorkspace={activeWorkspace}
workspaces={workspaces}
subscription={subscription}
>
<SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex"> <SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex">
<AppSidebar /> <AppSidebar />