feat: add customers list on the dashboard

This commit is contained in:
swve 2024-11-03 00:04:00 +01:00
parent 3988ee1d4b
commit 9f1d8c58d1
8 changed files with 361 additions and 7 deletions

View file

@ -20,6 +20,7 @@ from src.services.payments.payments_courses import (
from src.services.payments.payments_webhook import handle_stripe_webhook
from src.services.payments.stripe import create_checkout_session
from src.services.payments.payments_access import check_course_paid_access
from src.services.payments.payments_customers import get_customers
router = APIRouter()
@ -205,3 +206,15 @@ async def api_check_course_paid_access(
db_session=db_session
)
}
@router.get("/{org_id}/customers")
async def api_get_customers(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Get list of customers and their subscriptions for an organization
"""
return await get_customers(request, org_id, current_user, db_session)

View file

@ -0,0 +1,51 @@
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from src.db.organizations import Organization
from src.db.users import PublicUser, AnonymousUser, User
from src.db.payments.payments_users import PaymentsUser
from src.db.payments.payments_products import PaymentsProduct
from src.services.orgs.orgs import rbac_check
from src.services.payments.payments_products import get_payments_product
from src.services.users.users import read_user_by_id
async def get_customers(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
# Get all payment users for the organization
statement = select(PaymentsUser).where(PaymentsUser.org_id == org_id)
payment_users = db_session.exec(statement).all()
customers_data = []
for payment_user in payment_users:
# Get user data
user = await read_user_by_id(request, db_session, current_user, payment_user.user_id)
# Get product data
if org.id is None:
raise HTTPException(status_code=400, detail="Invalid organization ID")
product = await get_payments_product(request, org.id, payment_user.payment_product_id, current_user, db_session)
customer_data = {
'payment_user_id': payment_user.id,
'user': user if user else None,
'product': product if product else None,
'status': payment_user.status,
'creation_date': payment_user.creation_date,
'update_date': payment_user.update_date
}
customers_data.append(customer_data)
return customers_data

View file

@ -9,6 +9,7 @@ import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import PaymentsConfigurationPage from '@components/Dashboard/Payments/PaymentsConfigurationPage'
import PaymentsProductPage from '@components/Dashboard/Payments/PaymentsProductPage'
import PaymentsCustomersPage from '@components/Dashboard/Payments/PaymentsCustomersPage'
@ -101,7 +102,7 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
{selectedSubPage === 'general' && <div>General</div>}
{selectedSubPage === 'configuration' && <PaymentsConfigurationPage />}
{selectedSubPage === 'paid-products' && <PaymentsProductPage />}
{selectedSubPage === 'customers' && <div>Customers</div>}
{selectedSubPage === 'customers' && <PaymentsCustomersPage />}
</motion.div>
</div>
)

View file

@ -0,0 +1,137 @@
import React from 'react'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import useSWR from 'swr'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { getOrgCustomers } from '@services/payments/payments'
import { Badge } from '@/components/ui/badge'
import PageLoading from '@components/Objects/Loaders/PageLoading'
import { RefreshCcw, SquareCheck } from 'lucide-react'
import { getUserAvatarMediaDirectory } from '@services/media/media'
import UserAvatar from '@components/Objects/UserAvatar'
interface PaymentUserData {
payment_user_id: number;
user: {
username: string;
first_name: string;
last_name: string;
email: string;
avatar_image: string;
user_uuid: string;
};
product: {
name: string;
description: string;
product_type: string;
amount: number;
currency: string;
};
status: string;
creation_date: string;
}
function PaymentsUsersTable({ data }: { data: PaymentUserData[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Product</TableHead>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchase Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.payment_user_id}>
<TableCell className="font-medium">
<div className="flex items-center space-x-3">
<UserAvatar
border="border-2"
rounded="rounded-md"
avatar_url={getUserAvatarMediaDirectory(item.user.user_uuid, item.user.avatar_image)}
/>
<div className="flex flex-col">
<span className="font-medium">
{item.user.first_name || item.user.username}
</span>
<span className="text-sm text-gray-500">{item.user.email}</span>
</div>
</div>
</TableCell>
<TableCell>{item.product.name}</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
{item.product.product_type === 'subscription' ? (
<Badge variant="outline" className="flex items-center gap-1">
<RefreshCcw size={12} />
<span>Subscription</span>
</Badge>
) : (
<Badge variant="outline" className="flex items-center gap-1">
<SquareCheck size={12} />
<span>One-time</span>
</Badge>
)}
</div>
</TableCell>
<TableCell>
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: item.product.currency
}).format(item.product.amount)}
</TableCell>
<TableCell>
<Badge
variant={item.status === 'active' ? 'default' :
item.status === 'completed' ? 'default' : 'secondary'}
>
{item.status}
</Badge>
</TableCell>
<TableCell>
{new Date(item.creation_date).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function PaymentsCustomersPage() {
const org = useOrg() as any
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const { data: customers, error, isLoading } = useSWR(
org ? [`/payments/${org.id}/customers`, access_token] : null,
([url, token]) => getOrgCustomers(org.id, token)
)
if (isLoading) return <PageLoading />
if (error) return <div>Error loading customers</div>
return (
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-xl text-gray-800">Customers</h1>
<h2 className="text-gray-500 text-md">View and manage your customer information</h2>
</div>
<PaymentsUsersTable data={customers} />
</div>
)
}
export default PaymentsCustomersPage

View file

@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View file

@ -31,6 +31,7 @@
"@sentry/nextjs": "^8.35.0",
"@sentry/utils": "^8.35.0",
"@stitches/react": "^1.2.8",
"@tanstack/react-table": "^8.20.5",
"@tiptap/core": "^2.9.1",
"@tiptap/extension-code-block-lowlight": "^2.9.1",
"@tiptap/extension-collaboration": "^2.9.1",

View file

@ -68,6 +68,9 @@ importers:
'@stitches/react':
specifier: ^1.2.8
version: 1.2.8(react@18.3.1)
'@tanstack/react-table':
specifier: ^8.20.5
version: 8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tiptap/core':
specifier: ^2.9.1
version: 2.9.1(@tiptap/pm@2.9.1)
@ -1539,6 +1542,17 @@ packages:
'@swc/helpers@0.5.5':
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
'@tanstack/react-table@8.20.5':
resolution: {integrity: sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.20.5':
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
engines: {node: '>=12'}
'@tiptap/core@2.9.1':
resolution: {integrity: sha512-tifnLL/ARzQ6/FGEJjVwj9UT3v+pENdWHdk9x6F3X0mB1y0SeCjV21wpFLYESzwNdBPAj8NMp8Behv7dBnhIfw==}
peerDependencies:
@ -5523,6 +5537,14 @@ snapshots:
'@swc/counter': 0.1.3
tslib: 2.8.0
'@tanstack/react-table@8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/table-core': 8.20.5
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/table-core@8.20.5': {}
'@tiptap/core@2.9.1(@tiptap/pm@2.9.1)':
dependencies:
'@tiptap/pm': 2.9.1
@ -6486,7 +6508,7 @@ snapshots:
'@typescript-eslint/parser': 8.12.2(eslint@8.57.1)(typescript@5.4.4)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.2(eslint@8.57.1)
@ -6506,13 +6528,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1):
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.3.7
enhanced-resolve: 5.17.1
eslint: 8.57.1
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
fast-glob: 3.3.2
get-tsconfig: 4.8.1
is-bun-module: 1.2.1
@ -6525,14 +6547,14 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.12.2(eslint@8.57.1)(typescript@5.4.4)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@ -6547,7 +6569,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.12.2(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3

View file

@ -45,3 +45,12 @@ export async function deletePaymentConfig(orgId: number, id: string, access_toke
const res = await errorHandling(result);
return res;
}
export async function getOrgCustomers(orgId: number, access_token: string) {
const result = await fetch(
`${getAPIUrl()}payments/${orgId}/customers`,
RequestBodyWithAuthHeader('GET', null, null, access_token)
);
const res = await errorHandling(result);
return res;
}