Merge pull request #140 from vernu/dev

Dev
This commit is contained in:
Israel Abebe
2025-09-21 23:57:45 +03:00
committed by GitHub
15 changed files with 2253 additions and 330 deletions

View File

@@ -7,6 +7,7 @@ import {
Controller,
Get,
UseGuards,
Query,
} from '@nestjs/common'
import { WebhookService } from './webhook.service'
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'
@@ -27,7 +28,30 @@ export class WebhookController {
})
return { data }
}
@Get('notifications')
@UseGuards(AuthGuard)
async getWebhookNotifications(
@Request() req,
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('status') status?: string,
@Query('eventType') eventType?: string,
@Query('deviceId') deviceId?: string,
@Query('start') start?: Date,
@Query('end') end?: Date,
) {
const data = await this.webhookService.findWebhookNotificationsForUser({
user: req.user,
page,
limit,
eventType,
status,
start,
end,
deviceId,
})
return { data }
}
@Get(':webhookId')
@UseGuards(AuthGuard)
async getWebhook(@Request() req, @Param('webhookId') webhookId: string) {

View File

@@ -15,6 +15,7 @@ import { v4 as uuidv4 } from 'uuid'
import { Cron } from '@nestjs/schedule'
import { CronExpression } from '@nestjs/schedule'
import * as crypto from 'crypto'
import mongoose from 'mongoose'
@Injectable()
export class WebhookService {
@@ -40,7 +41,181 @@ export class WebhookService {
async findWebhooksForUser({ user }) {
return await this.webhookSubscriptionModel.find({ user: user._id })
}
async findWebhookNotificationsForUser({
user,
page = 1,
limit = 10,
eventType,
status,
start,
end,
deviceId,
}): Promise<{ data: any[]; meta: any }> {
const userWebhookSubscription = await this.webhookSubscriptionModel.findOne(
{
user: user._id,
},
)
if (!userWebhookSubscription) {
return {
data: [],
meta: {
page: 1,
limit,
total: 0,
totalPages: 0,
},
}
}
const matchStage: any = { webhookSubscription: userWebhookSubscription._id }
if (eventType) {
matchStage.event = eventType
}
if (
start &&
end &&
!Number.isNaN(new Date(start).getTime()) &&
!Number.isNaN(new Date(end).getTime())
) {
matchStage.createdAt = { $gte: new Date(start), $lte: new Date(end) }
}
const pageNum = Math.max(1, Number.parseInt(page.toString()) || 1)
const limitNum = Math.max(1, Number.parseInt(limit.toString()) || 10)
const skip = (pageNum - 1) * limitNum
const commonPipeline: any[] = [
{ $match: matchStage },
{
$lookup: {
from: 'sms',
localField: 'sms',
foreignField: '_id',
as: 'smsData',
},
},
{ $unwind: { path: '$smsData', preserveNullAndEmptyArrays: true } },
{
$lookup: {
from: 'devices',
localField: 'smsData.device',
foreignField: '_id',
as: 'deviceData',
},
},
{ $unwind: { path: '$deviceData', preserveNullAndEmptyArrays: true } },
{
$lookup: {
from: 'webhooksubscriptions',
localField: 'webhookSubscription',
foreignField: '_id',
as: 'webhookSubscriptionData',
},
},
{
$unwind: {
path: '$webhookSubscriptionData',
preserveNullAndEmptyArrays: true,
},
},
{
$addFields: {
computedStatus: {
$cond: {
if: { $ne: ['$deliveredAt', null] },
then: 'delivered',
else: {
$cond: {
if: {
$or: [
{ $ne: ['$deliveryAttemptAbortedAt', null] },
{ $gte: ['$deliveryAttemptCount', 10] }
]
},
then: 'failed',
else: {
$cond: {
if: {
$and: [
{ $eq: ['$deliveredAt', null] },
{ $eq: ['$deliveryAttemptAbortedAt', null] },
{ $gt: ['$deliveryAttemptCount', 0] },
{ $lt: ['$deliveryAttemptCount', 10] }
]
},
then: 'retrying',
else: 'pending'
}
}
}
}
}
}
}
},
]
// Apply status filter
if (status) {
commonPipeline.push({
$match: {
computedStatus: status
}
})
}
if (deviceId) {
commonPipeline.push({
$match: {
'deviceData._id': new mongoose.Types.ObjectId(deviceId),
},
})
}
const facetPipeline = [
...commonPipeline,
{
$facet: {
totalCount: [{ $count: 'count' }],
data: [
{ $sort: { createdAt: -1 } },
{ $skip: skip },
{ $limit: limitNum },
],
},
},
{
$project: {
data: 1,
totalCount: {
$ifNull: [{ $arrayElemAt: ['$totalCount.count', 0] }, 0],
},
},
},
]
const [result] =
await this.webhookNotificationModel.aggregate(facetPipeline)
const total = result?.totalCount || 0
const data = result?.data || []
const totalPages = Math.ceil(total / limitNum)
return {
data,
meta: {
page: pageNum,
limit: limitNum,
total,
totalPages,
},
}
}
async create({ user, createWebhookDto }) {
const { events, deliveryUrl, signingSecret } = createWebhookDto
@@ -164,9 +339,7 @@ export class WebhookService {
)
if (!webhookSubscription) {
console.log(
`Webhook subscription not found for ${webhookSubscriptionId}`,
)
console.log(`Webhook subscription not found for ${webhookSubscriptionId}`)
return
}
@@ -217,7 +390,6 @@ export class WebhookService {
webhookSubscription.deliveryFailureCount += 1
webhookSubscription.lastDeliveryFailureAt = now
} finally {
webhookSubscription.deliveryAttemptCount += 1
await webhookSubscription.save()
@@ -250,7 +422,7 @@ export class WebhookService {
// Check for notifications that need to be delivered every 3 minutes
@Cron('0 */3 * * * *', {
disabled: process.env.NODE_ENV !== 'production'
disabled: process.env.NODE_ENV !== 'production',
})
async checkForNotificationsToDeliver() {
const now = new Date()

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation } from '@tanstack/react-query'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
@@ -16,6 +16,8 @@ import {
Smartphone,
RefreshCw,
Timer,
Copy,
Trash2,
} from 'lucide-react'
import {
Select,
@@ -30,38 +32,94 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { sendSmsSchema } from '@/lib/schemas'
import type { SendSmsFormData } from '@/lib/schemas'
import { useMutation } from '@tanstack/react-query'
import { Spinner } from '@/components/ui/spinner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { toast } from '@/hooks/use-toast'
function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
const [open, setOpen] = useState(false)
// Helper function to format timestamps
const formatTimestamp = (timestamp: string | null | undefined) => {
if (!timestamp) return 'N/A'
return new Date(timestamp).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
// Helper to get status color and icon
const getStatusBadge = (status: string) => {
const normalizedStatus = status?.toLowerCase() || 'pending'
switch (normalizedStatus) {
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
icon: <Timer className='h-3 w-3' />,
label: 'Pending',
}
case 'sent':
return {
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
icon: <Check className='h-3 w-3' />,
label: 'Sent',
}
case 'delivered':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
icon: <Check className='h-3 w-3' />,
label: 'Delivered',
}
case 'failed':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
icon: <X className='h-3 w-3' />,
label: 'Failed',
}
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
icon: <Timer className='h-3 w-3' />,
label: normalizedStatus,
}
}
}
function ReplyDialog({ sms, onClose, open, onOpenChange }: { sms: any; onClose?: () => void; open: boolean; onOpenChange: (open: boolean) => void }) {
const {
mutate: sendSms,
isPending: isSendingSms,
error: sendSmsError,
isSuccess: isSendSmsSuccess,
} = useMutation({
mutationKey: ['send-sms'],
mutationFn: (data: SendSmsFormData) =>
httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data),
onSuccess: () => {
toast({
title: 'SMS sent successfully!',
})
setTimeout(() => {
setOpen(false)
onOpenChange(false)
if (onClose) onClose()
}, 1500)
},
onError: (error: any) => {
toast({
title: 'Failed to send SMS.',
description: error.response?.data?.message || 'Please try again.',
})
},
})
const {
@@ -79,12 +137,9 @@ function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
},
})
const { data: devices, isLoading: isLoadingDevices } = useQuery({
const { data: devices } = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
queryFn: () => httpBrowserClient.get(ApiEndpoints.gateway.listDevices()).then((res) => res.data),
})
useEffect(() => {
@@ -98,25 +153,19 @@ function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
}, [open, sms, reset])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='ghost' size='sm' className='gap-1'>
<Reply className='h-3.5 w-3.5' />
Reply
</Button>
</DialogTrigger>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<MessageSquare className='h-5 w-5' />
<Reply className='h-5 w-5' />
Reply to {sms.sender}
</DialogTitle>
<DialogDescription>
Send a reply message to this sender
Send a reply message to this sender.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => handleSubmit((data) => sendSms(data))(e)}
onSubmit={handleSubmit((data) => sendSms(data))}
className='space-y-4 mt-4'
>
<div className='space-y-4'>
@@ -134,7 +183,7 @@ function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
<SelectValue placeholder='Select a device' />
</SelectTrigger>
<SelectContent>
{devices?.data?.map((device) => (
{devices?.data?.map((device: any) => (
<SelectItem key={device._id} value={device._id}>
{device.brand} {device.model}{' '}
{device.enabled ? '' : '(disabled)'}
@@ -150,7 +199,6 @@ function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
</p>
)}
</div>
<div>
<Input
type='tel'
@@ -163,7 +211,6 @@ function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
</p>
)}
</div>
<div>
<Textarea
placeholder='Message'
@@ -177,25 +224,11 @@ function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
)}
</div>
</div>
{sendSmsError && (
<div className='flex items-center gap-2 text-destructive'>
<p>Error sending SMS: {sendSmsError.message}</p>
<X className='h-5 w-5' />
</div>
)}
{isSendSmsSuccess && (
<div className='flex items-center gap-2 text-green-600'>
<p>SMS sent successfully!</p>
<Check className='h-5 w-5' />
</div>
)}
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='outline'
onClick={() => setOpen(false)}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
@@ -212,30 +245,30 @@ function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) {
)
}
function FollowUpDialog({
message,
onClose,
}: {
message: any
onClose?: () => void
}) {
const [open, setOpen] = useState(false)
function FollowUpDialog({ message, onClose, open, onOpenChange }: { message: any; onClose?: () => void; open: boolean; onOpenChange: (open: boolean) => void }) {
const {
mutate: sendSms,
isPending: isSendingSms,
error: sendSmsError,
isSuccess: isSendSmsSuccess,
} = useMutation({
mutationKey: ['send-sms'],
mutationFn: (data: SendSmsFormData) =>
httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data),
onSuccess: () => {
toast({
title: 'Follow-up SMS sent successfully!',
})
setTimeout(() => {
setOpen(false)
onOpenChange(false)
if (onClose) onClose()
}, 1500)
},
onError: (error: any) => {
toast({
title: 'Failed to send follow-up SMS.',
description: error.response?.data?.message || 'Please try again.',
})
},
})
const {
@@ -250,19 +283,16 @@ function FollowUpDialog({
deviceId: message?.device?._id,
recipients: [
message.recipient ||
(message.recipients && message.recipients[0]) ||
'',
(message.recipients && message.recipients[0]) ||
'',
],
message: '',
},
})
const { data: devices, isLoading: isLoadingDevices } = useQuery({
const { data: devices } = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
queryFn: () => httpBrowserClient.get(ApiEndpoints.gateway.listDevices()).then((res) => res.data),
})
useEffect(() => {
@@ -271,8 +301,8 @@ function FollowUpDialog({
deviceId: message?.device?._id,
recipients: [
message.recipient ||
(message.recipients && message.recipients[0]) ||
'',
(message.recipients && message.recipients[0]) ||
'',
],
message: '',
})
@@ -280,13 +310,7 @@ function FollowUpDialog({
}, [open, message, reset])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='ghost' size='sm' className='gap-1'>
<MessageSquare className='h-3.5 w-3.5' />
Follow Up
</Button>
</DialogTrigger>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
@@ -297,11 +321,11 @@ function FollowUpDialog({
'Recipient'}
</DialogTitle>
<DialogDescription>
Send a follow-up message to this recipient
Send a follow-up message to this recipient.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => handleSubmit((data) => sendSms(data))(e)}
onSubmit={handleSubmit((data) => sendSms(data))}
className='space-y-4 mt-4'
>
<div className='space-y-4'>
@@ -319,7 +343,7 @@ function FollowUpDialog({
<SelectValue placeholder='Select a device' />
</SelectTrigger>
<SelectContent>
{devices?.data?.map((device) => (
{devices?.data?.map((device: any) => (
<SelectItem key={device._id} value={device._id}>
{device.brand} {device.model}{' '}
{device.enabled ? '' : '(disabled)'}
@@ -335,7 +359,6 @@ function FollowUpDialog({
</p>
)}
</div>
<div>
<Input
type='tel'
@@ -348,7 +371,6 @@ function FollowUpDialog({
</p>
)}
</div>
<div>
<Textarea
placeholder='Message'
@@ -362,25 +384,11 @@ function FollowUpDialog({
)}
</div>
</div>
{sendSmsError && (
<div className='flex items-center gap-2 text-destructive'>
<p>Error sending SMS: {sendSmsError.message}</p>
<X className='h-5 w-5' />
</div>
)}
{isSendSmsSuccess && (
<div className='flex items-center gap-2 text-green-600'>
<p>SMS sent successfully!</p>
<Check className='h-5 w-5' />
</div>
)}
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='outline'
onClick={() => setOpen(false)}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
@@ -397,164 +405,169 @@ function FollowUpDialog({
)
}
function StatusDetailsDialog({ message }: { message: any }) {
const [open, setOpen] = useState(false);
// Format timestamps for display
const formatTimestamp = (timestamp) => {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
// Get status badge color and icon based on message status
const getStatusBadge = () => {
const status = message.status?.toLowerCase() || 'pending';
switch (status) {
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
icon: <Timer className="h-3 w-3 mr-1" />,
label: 'Pending'
};
case 'sent':
return {
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
icon: <Check className="h-3 w-3 mr-1" />,
label: 'Sent'
};
case 'delivered':
return {
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
icon: <Check className="h-3 w-3 mr-1" />,
label: 'Delivered'
};
case 'failed':
return {
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
icon: <X className="h-3 w-3 mr-1" />,
label: 'Failed'
};
default:
return {
color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
icon: <Timer className="h-3 w-3 mr-1" />,
label: status
};
}
};
// New component for full SMS details
function SmsDetailsDialog({
message,
open,
onOpenChange,
}: {
message: any
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [isReplyOpen, setIsReplyOpen] = useState(false)
const [isFollowUpOpen, setIsFollowUpOpen] = useState(false)
const statusBadge = getStatusBadge(message?.status)
const isSent = !!message?.recipient || (message?.recipients && message.recipients.length > 0)
const handleCopyMessage = () => {
if (message?.message) {
navigator.clipboard.writeText(message.message)
toast({
title: 'Message copied to clipboard!',
})
}
}
const handleReplyClick = () => {
onOpenChange(false)
setIsReplyOpen(true)
}
const handleFollowUpClick = () => {
onOpenChange(false)
setIsFollowUpOpen(true)
}
const handleReplyDialogClose = () => setIsReplyOpen(false)
const handleFollowUpDialogClose = () => setIsFollowUpOpen(false)
const statusBadge = getStatusBadge();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Badge variant="outline" className={`${statusBadge.color} flex items-center text-xs cursor-pointer`}>
{statusBadge.icon}
{statusBadge.label}
</Badge>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
SMS Status Details
</DialogTitle>
<DialogDescription>
Detailed information about this SMS message
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="font-medium">Status</div>
<div>{message.status || 'pending'}</div>
<div className="font-medium">Requested At</div>
<div>{formatTimestamp(message.requestedAt)}</div>
<div className="font-medium">Sent At</div>
<div>{formatTimestamp(message.sentAt)}</div>
<div className="font-medium">Delivered At</div>
<div>{formatTimestamp(message.deliveredAt)}</div>
{message.status?.toLowerCase() === 'failed' && (
<>
<div className="font-medium">Failed At</div>
<div>{formatTimestamp(message.failedAt)}</div>
{message.errorCode && (
<>
<div className="font-medium">Error Code</div>
<div className="">{message.errorCode}</div>
</>
)}
{message.errorMessage && (
<>
<div className="font-medium">Error Message</div>
<div className="">{message.errorMessage}</div>
</>
)}
{(!message.errorCode && !message.errorMessage && message.error) && (
<>
<div className="font-medium">Error</div>
<div className="text-destructive">{message.error || 'Unknown error'}</div>
</>
)}
</>
)}
<div className="font-medium">Recipient</div>
<div>{message.recipient || (message.recipients && message.recipients[0]) || 'Unknown'}</div>
<div className="font-medium">Message ID</div>
<div className="font-mono text-xs">{message._id}</div>
{message.smsBatch && (
<>
<div className="font-medium">Batch ID</div>
<div className="font-mono text-xs">{message.smsBatch}</div>
</>
)}
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[550px] p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<MessageSquare className="h-5 w-5 text-brand-500" />
SMS Details
</DialogTitle>
<DialogDescription>
Detailed information about this SMS message.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4 text-sm">
{/* Info Grid */}
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<div className="font-medium text-muted-foreground">Direction</div>
<div className="flex items-center gap-1">
{isSent ? <ArrowUpRight className="h-4 w-4 text-brand-500" /> :
<ArrowDownLeft className="h-4 w-4 text-green-500" />}
<span className="capitalize">{isSent ? 'Sent' : 'Received'}</span>
</div>
<div className="font-medium text-muted-foreground">Number</div>
<div>
{isSent ? message.recipient || message.recipients?.[0] || 'Unknown'
: message.sender || 'Unknown'}
</div>
<div className="font-medium text-muted-foreground">Status</div>
<div>
<Badge variant="outline" className={`${statusBadge.color} flex items-center text-xs`}>
{statusBadge.icon}
{statusBadge.label}
</Badge>
</div>
<div className="font-medium text-muted-foreground">Date & Time</div>
<div>{formatTimestamp(isSent ? message.requestedAt : message.receivedAt)}</div>
<div className="font-medium text-muted-foreground">Device</div>
<div className="flex items-center gap-1">
<Smartphone className="h-3 w-3" />
{message.device?.brand || 'N/A'} {message.device?.model || ''}
</div>
{message.gatewayMessageId && (
<>
<div className="font-medium text-muted-foreground">Gateway ID</div>
<div className="font-mono text-xs break-all">{message.gatewayMessageId}</div>
</>
)}
{message.errorMessage && (
<>
<div className="font-medium text-muted-foreground">Error</div>
<div className="text-destructive text-sm">{message.errorMessage}</div>
</>
)}
</div>
{/* Message Body */}
<div className="pt-4 border-t border-border">
<h4 className="font-medium text-sm text-muted-foreground mb-1">Message Body</h4>
<div className="max-h-48 overflow-y-auto p-2 bg-gray-50 dark:bg-gray-900 rounded-md text-sm break-words">
{message.message}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2 mt-4 pt-2 border-t border-border">
{!isSent && (
<Button variant="ghost" size="sm" className="gap-1" onClick={handleReplyClick}>
<Reply className="h-4 w-4" />
Reply
</Button>
)}
{isSent && (
<Button variant="ghost" size="sm" className="gap-1" onClick={handleFollowUpClick}>
<MessageSquare className="h-4 w-4" />
Follow Up
</Button>
)}
<Button variant="ghost" size="sm" className="gap-1" onClick={handleCopyMessage}>
<Copy className="h-4 w-4" />
Copy
</Button>
{/* Optional Delete */}
{/* <Button variant="ghost" size="sm" className="gap-1 text-destructive hover:bg-destructive/10">
<Trash2 className="h-4 w-4" />
Delete
</Button> */}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
</DialogContent>
</Dialog>
{message && isReplyOpen && (
<ReplyDialog sms={message} open={isReplyOpen} onOpenChange={handleReplyDialogClose} />
)}
{message && isFollowUpOpen && (
<FollowUpDialog message={message} open={isFollowUpOpen} onOpenChange={handleFollowUpDialogClose} />
)}
</>
)
}
function MessageCard({ message, type, device }) {
function MessageCard({ message, type, device, onSelectMessage }) {
const isSent = type === 'sent'
const formattedDate = new Date(
const formattedDate = formatTimestamp(
(isSent ? message.requestedAt : message.receivedAt) || message.createdAt
).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
day: 'numeric',
month: 'short',
year: 'numeric',
})
)
const statusBadge = getStatusBadge(message.status)
const shouldShowStatus = device?.appVersionCode >= 14 && new Date(message?.createdAt) > new Date('2025-06-05')
// Condition to show status badge based on device app version and message date
const shouldShowStatus = device?.appVersionCode >= 14 && new Date(message?.createdAt) > new Date('2025-06-05')
return (
<Card
className={`hover:bg-muted/50 transition-colors max-w-sm md:max-w-none ${
isSent
? 'border-l-4 border-l-brand-500'
: 'border-l-4 border-l-green-500'
className={`hover:bg-muted/50 transition-colors cursor-pointer max-w-sm md:max-w-none ${
isSent ? 'border-l-4 border-l-brand-500' : 'border-l-4 border-l-green-500'
}`}
onClick={() => onSelectMessage(message)}
>
<CardContent className='p-4'>
<div className='space-y-3'>
@@ -584,20 +597,16 @@ function MessageCard({ message, type, device }) {
</div>
<div className='flex gap-2'>
<p className='text-sm max-w-sm md:max-w-none'>{message.message}</p>
<p className='text-sm max-w-sm md:max-w-none line-clamp-2'>{message.message}</p>
</div>
<div className='flex justify-between items-center'>
{isSent && shouldShowStatus && (
<div className='flex items-center'>
<StatusDetailsDialog message={message} />
</div>
<Badge variant='outline' className={`${statusBadge.color} flex items-center text-xs`}>
{statusBadge.icon}
{statusBadge.label}
</Badge>
)}
<div className='flex justify-end ml-auto'>
{!isSent && <ReplyDialog sms={message} />}
{isSent && <FollowUpDialog message={message} />}
</div>
</div>
</div>
</CardContent>
@@ -622,33 +631,37 @@ function MessageCardSkeleton() {
}
export default function MessageHistory() {
const [selectedMessage, setSelectedMessage] = useState(null)
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false)
const handleSelectMessage = (message: any) => {
setSelectedMessage(message)
setIsDetailsDialogOpen(true)
}
const {
data: devices,
isLoading: isLoadingDevices,
error: devicesError,
} = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
queryFn: () => httpBrowserClient.get(ApiEndpoints.gateway.listDevices()).then((res) => res.data),
})
const [currentDevice, setCurrentDevice] = useState('')
const [messageType, setMessageType] = useState('all')
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [autoRefreshInterval, setAutoRefreshInterval] = useState(0) // 0 means no auto-refresh
const [autoRefreshInterval, setAutoRefreshInterval] = useState(0)
const [isRefreshing, setIsRefreshing] = useState(false)
const refreshTimerRef = useRef(null)
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (devices?.data?.length) {
setCurrentDevice(devices?.data?.[0]?._id)
if (devices?.data?.length && !currentDevice) {
setCurrentDevice(devices.data[0]._id)
}
}, [devices])
}, [devices, currentDevice])
// Query for messages with type filter
const {
data: messagesResponse,
isLoading: isLoadingMessages,
@@ -667,34 +680,27 @@ export default function MessageHistory() {
.then((res) => res.data),
})
// Handle manual refresh
const handleRefresh = async () => {
if (!currentDevice) return // Don't refresh if no device is selected
if (!currentDevice) return
setIsRefreshing(true)
await refetch()
setTimeout(() => setIsRefreshing(false), 500) // Show refresh animation for at least 500ms
setTimeout(() => setIsRefreshing(false), 500)
}
// Setup auto-refresh timer
useEffect(() => {
// Clear any existing timer
if (refreshTimerRef.current) {
clearInterval(refreshTimerRef.current)
refreshTimerRef.current = null
}
// Set up new timer if interval > 0
if (autoRefreshInterval > 0 && currentDevice) {
refreshTimerRef.current = setInterval(() => {
refetch()
// Brief visual feedback that refresh happened
setIsRefreshing(true)
setTimeout(() => setIsRefreshing(false), 300)
}, autoRefreshInterval * 1000)
}
// Cleanup on unmount
return () => {
if (refreshTimerRef.current) {
clearInterval(refreshTimerRef.current)
@@ -711,17 +717,17 @@ export default function MessageHistory() {
totalPages: 1,
}
const handleDeviceChange = (deviceId) => {
const handleDeviceChange = (deviceId: string) => {
setCurrentDevice(deviceId)
setPage(1)
}
const handleMessageTypeChange = (type) => {
const handleMessageTypeChange = (type: string) => {
setMessageType(type)
setPage(1)
}
const handlePageChange = (newPage) => {
const handlePageChange = (newPage: number) => {
setPage(newPage)
}
@@ -766,7 +772,7 @@ export default function MessageHistory() {
<SelectValue placeholder='Select a device' />
</SelectTrigger>
<SelectContent>
{devices?.data?.map((device) => (
{devices?.data?.map((device: any) => (
<SelectItem key={device._id} value={device._id}>
<div className='flex items-center gap-2'>
<span className='font-medium'>
@@ -825,7 +831,6 @@ export default function MessageHistory() {
</div>
</div>
{/* Refresh Controls */}
<div className='flex items-center justify-between gap-2 pt-2 mt-2 border-t border-brand-100 dark:border-brand-800/50'>
<div className='flex items-center gap-1.5'>
<Button
@@ -842,18 +847,11 @@ export default function MessageHistory() {
/>
Refresh Now
</Button>
{/* {messagesResponse && (
<span className='text-xs text-muted-foreground hidden sm:inline-block'>
Updated: {new Date().toLocaleTimeString()}
</span>
)} */}
</div>
<div className='flex items-center gap-1.5'>
<Timer className='h-3 w-3 text-brand-500' />
<span className='text-xs font-medium mr-1'>Auto Refresh:</span>
<div className='flex'>
{[
{ value: 0, label: 'Off' },
@@ -896,19 +894,20 @@ export default function MessageHistory() {
</div>
)}
{!isLoadingDevices && !messages && (
{!isLoadingMessages && messages.length === 0 && (
<div className='flex justify-center items-center h-full py-10'>
No messages found
</div>
)}
<div className='space-y-4'>
{messages?.map((message) => (
{messages?.map((message: any) => (
<MessageCard
key={message._id}
message={message}
type={message.sender ? 'received' : 'sent'}
device={devices?.data?.find((device) => device._id === currentDevice)}
device={devices?.data?.find((device: any) => device._id === currentDevice)}
onSelectMessage={handleSelectMessage}
/>
))}
</div>
@@ -924,7 +923,6 @@ export default function MessageHistory() {
</Button>
<div className='flex flex-wrap items-center gap-2 justify-center sm:justify-start'>
{/* First page */}
{pagination.totalPages > 1 && (
<Button
onClick={() => handlePageChange(1)}
@@ -940,32 +938,24 @@ export default function MessageHistory() {
</Button>
)}
{/* Ellipsis if needed */}
{page > 4 && pagination.totalPages > 7 && (
<span className='px-1'>...</span>
)}
{/* Middle pages */}
{Array.from(
{ length: Math.min(6, pagination.totalPages - 2) },
(_, i) => {
let pageToShow
if (pagination.totalPages <= 8) {
// If we have 8 or fewer pages, show pages 2 through 7 (or fewer)
pageToShow = i + 2
} else if (page <= 4) {
// Near the start
pageToShow = i + 2
} else if (page >= pagination.totalPages - 3) {
// Near the end
pageToShow = pagination.totalPages - 7 + i
} else {
// Middle - center around current page
pageToShow = page - 2 + i
}
// Ensure page is within bounds and not the first or last page
if (pageToShow > 1 && pageToShow < pagination.totalPages) {
return (
<Button
@@ -987,12 +977,10 @@ export default function MessageHistory() {
}
)}
{/* Ellipsis if needed */}
{page < pagination.totalPages - 3 && pagination.totalPages > 7 && (
<span className='px-1'>...</span>
)}
{/* Last page */}
{pagination.totalPages > 1 && (
<Button
onClick={() => handlePageChange(pagination.totalPages)}
@@ -1008,11 +996,8 @@ export default function MessageHistory() {
</Button>
)}
</div>
<Button
onClick={() =>
handlePageChange(Math.min(pagination.totalPages, page + 1))
}
onClick={() => handlePageChange(Math.min(pagination.totalPages, page + 1))}
disabled={page === pagination.totalPages}
variant={page === pagination.totalPages ? 'ghost' : 'default'}
>
@@ -1020,6 +1005,13 @@ export default function MessageHistory() {
</Button>
</div>
)}
{selectedMessage && (
<SmsDetailsDialog
message={selectedMessage}
open={isDetailsDialogOpen}
onOpenChange={setIsDetailsDialogOpen}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,518 @@
'use client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ApiEndpoints } from '@/config/api'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { useQuery } from '@tanstack/react-query'
import {
ArrowDownLeft,
ArrowUpRight,
Clock,
Ellipsis,
MessageSquare,
Smartphone,
} from 'lucide-react'
import React, { useEffect, useRef, useState } from 'react'
import ProductClient from '../webhooks/(components)/webhook-table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Calendar } from 'lucide-react'
import { truncate } from 'fs'
const WebhooksHistory = () => {
const {
data: devices,
isLoading: isLoadingDevices,
error: devicesError,
} = useQuery({
queryKey: ['devices'],
queryFn: () =>
httpBrowserClient
.get(ApiEndpoints.gateway.listDevices())
.then((res) => res.data),
})
const [currentDevice, setCurrentDevice] = useState('all')
const [eventType, setEventType] = useState('all')
const [status, setStatus] = useState('all')
const [dateRange, setDateRange] = useState<any>('90')
const [openCal, setOpenCal] = useState(false)
const [dateQuery, setDateQuery] = useState<{ start: string; end: string }>({
start: '',
end: '',
})
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(10)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (devices?.data?.length && currentDevice === 'all') {
}
}, [devices, currentDevice])
const {
data: webhookNotifications,
isLoading: isLoadingNotifications,
error: webhookNotificationsError,
refetch,
} = useQuery({
queryKey: [
'webhook-notification',
eventType,
page,
limit,
currentDevice,
status,
],
enabled: true,
queryFn: () =>
httpBrowserClient
.get(
`${ApiEndpoints.gateway.getWebhookNotifications()}?eventType=${eventType === 'all' ? '' : eventType}&page=${page}&limit=${limit}&status=${status === 'all' ? '' : status}&start=${
dateQuery.start
}&end=${dateQuery.end}&deviceId=${
currentDevice === 'all' ? '' : currentDevice
}`
)
.then((res) => res.data),
})
const totalPages = webhookNotifications?.data?.meta?.totalPages
const handlePageChange = (currentPage: number) => {
setPage(currentPage)
}
const handleMessageTypeChange = (type: string) => {
setEventType(type)
setPage(1)
}
const handleStatusChange = (status: string) => {
setStatus(status)
setPage(1)
}
const handleDateRangeChange = (range: string) => {
const end = new Date() // today
const start = new Date()
switch (range) {
case '7':
start.setDate(end.getDate() - 7)
break
case '30':
start.setDate(end.getDate() - 30)
break
case '90':
start.setDate(end.getDate() - 90)
break
case '90_months':
start.setMonth(end.getMonth() - 3)
break
case '180':
start.setMonth(end.getMonth() - 6)
break
case '365':
start.setMonth(end.getMonth() - 12)
break
case 'custom':
setDateQuery({ start: '', end: '' })
setDateRange('custom')
setOpenCal(true)
return
}
setDateQuery({ start: start.toISOString(), end: end.toISOString() })
setDateRange(range)
setPage(1)
}
const handleDeviceChange = (deviceId: string) => {
setCurrentDevice(deviceId)
setPage(1)
}
return (
<div className="flex flex-col gap-y-4">
<div className="bg-gradient-to-r from-brand-50 to-sky-50 dark:from-brand-950/30 dark:to-sky-950/30 rounded-lg shadow-sm border border-brand-100 dark:border-brand-800/50 p-4 mb-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="">
<div className="flex items-center gap-2 mb-1.5">
<Smartphone className="h-3.5 w-3.5 text-brand-500" />
<h3 className="text-sm font-medium text-foreground">Device</h3>
</div>
<Select value={currentDevice} onValueChange={handleDeviceChange}>
<SelectTrigger className="w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-brand-200 dark:border-brand-800/70">
<SelectValue placeholder="Select a device" />
</SelectTrigger>
<SelectContent>
<SelectItem key="all" value="all">
All devices
</SelectItem>
{devices?.data?.map((device) => (
<SelectItem key={device._id} value={device._id}>
<div className="flex items-center gap-2">
<span className="font-medium">
{device.brand} {device.model}
</span>
{!device.enabled && (
<Badge
variant="outline"
className="ml-1 text-xs py-0 h-5"
>
Disabled
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-full sm:w-44">
<div className="flex items-center gap-2 mb-1.5">
<MessageSquare className="h-3.5 w-3.5 text-brand-500" />
<h3 className="text-sm font-medium text-foreground">
Event Type
</h3>
</div>
<Select value={eventType} onValueChange={handleMessageTypeChange}>
<SelectTrigger className="w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-brand-200 dark:border-brand-800/70">
<SelectValue placeholder="Message type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-gray-500" />
All Events
</div>
</SelectItem>
<SelectItem value="MESSAGE_RECEIVED">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
MESSAGE_RECEIVED
</div>
</SelectItem>
<SelectItem value="SMS_STATUS_UPDATED">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-brand-500" />
SMS_STATUS_UPDATED
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full sm:w-44">
<div className="flex items-center gap-2 mb-1.5">
<MessageSquare className="h-3.5 w-3.5 text-brand-500" />
<h3 className="text-sm font-medium text-foreground">Status</h3>
</div>
{/* status(delivered, pending, failed, retrying) */}
<Select value={status} onValueChange={handleStatusChange}>
<SelectTrigger className="w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-brand-200 dark:border-brand-800/70">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-gray-500" />
All
</div>
</SelectItem>
<SelectItem value="delivered">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
Delivered
</div>
</SelectItem>
<SelectItem value="pending">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-brand-500" />
Pending
</div>
</SelectItem>
<SelectItem value="failed">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-red-500" />
Failed
</div>
</SelectItem>
<SelectItem value="retrying">
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-1.5 rounded-full bg-gray-500" />
Retrying
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full sm:w-44">
<div className="flex items-center gap-2 mb-1.5">
<MessageSquare className="h-3.5 w-3.5 text-brand-500" />
<h3 className="text-sm font-medium text-foreground">
Date Range
</h3>{' '}
</div>
<Select value={dateRange} onValueChange={handleDateRangeChange}>
<SelectTrigger className="w-full bg-white/80 dark:bg-black/20 h-9 text-sm border-brand-200 dark:border-brand-800/70">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">
<div className="flex items-center gap-1.5">Last 7 Days</div>
</SelectItem>
<SelectItem value="30">
<div className="flex items-center gap-1.5">
Last 30 Days
</div>
</SelectItem>
<SelectItem value="90">
<div className="flex items-center gap-1.5">
Last 90 Days
</div>
</SelectItem>
<SelectItem value="90_months">
<div className="flex items-center gap-1.5">
Last 3 Months
</div>
</SelectItem>
<SelectItem value="180">
<div className="flex items-center gap-1.5">
Last 6 Months
</div>
</SelectItem>
<SelectItem value="365">
<div className="flex items-center gap-1.5">
Last 12 Months
</div>
</SelectItem>
<SelectItem value="custom">
<div className="flex items-center gap-1.5">Custom</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Popover open={openCal} onOpenChange={setOpenCal}>
<PopoverContent className="p-4 w-64">
<div className="flex flex-col gap-3">
<div>
<label
className="block text-xs mb-1"
htmlFor="start-date"
>
Start Date
</label>
<input
id="start-date"
type="date"
className="w-full border rounded px-2 py-1 text-sm"
value={dateQuery.start ? dateQuery.start.split('T')[0] : ''}
onChange={(e) =>
setDateQuery({
...dateQuery,
start: e.target.value ? new Date(e.target.value).toISOString() : '',
})
}
/>
</div>
<div>
<label
className="block text-xs mb-1"
htmlFor="end-date"
>
End Date
</label>
<input
id="end-date"
type="date"
className="w-full border rounded px-2 py-1 text-sm"
value={dateQuery.end ? dateQuery.end.split('T')[0] : ''}
onChange={(e) =>
setDateQuery({
...dateQuery,
end: e.target.value ? new Date(e.target.value).toISOString() : '',
})
}
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setOpenCal(false)
setDateRange('90')
const end = new Date()
const start = new Date()
start.setDate(end.getDate() - 90)
setDateQuery({ start: start.toISOString(), end: end.toISOString() })
}}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
setPage(1)
setOpenCal(false)
refetch()
}}
disabled={!dateQuery.start || !dateQuery.end}
>
Apply
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{isLoadingNotifications || isLoading ? (
<ProductClient data={[]} isLoading={true} />
) : (
<ProductClient
data={webhookNotifications?.data?.data || []}
isLoading={false}
status={status}
/>
)}
<div className="flex justify-end gap-x-3">
{totalPages > 1 && (
<div className="flex justify-center mt-6 space-x-2">
<Button
onClick={() => handlePageChange(Math.max(1, page - 1))}
disabled={page === 1 || isLoading || isLoadingNotifications}
variant={page === 1 ? 'ghost' : 'default'}
>
Previous
</Button>
<div className="flex flex-wrap items-center gap-2 justify-center sm:justify-start">
{totalPages > 1 && (
<Button
onClick={() => handlePageChange(1)}
variant={page === 1 ? 'default' : 'ghost'}
size="icon"
className={`h-8 w-8 rounded-full ${
page === 1
? 'bg-primary text-brand-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
1
</Button>
)}
{/* Ellipsis if needed */}
{page > 4 && totalPages > 7 && (
<span className="px-1">...</span>
)}
{Array.from(
{
length: Math.min(6, totalPages - 2),
},
(_, i) => {
let pageToShow: number
if (totalPages <= 8) {
// If we have 8 or fewer pages, show pages 2 through 7 (or fewer)
pageToShow = i + 2
} else if (page <= 4) {
// Near the start
pageToShow = i + 2
} else if (page >= totalPages - 3) {
// Near the end
pageToShow = totalPages - 7 + i
} else {
// Middle - center around current page
pageToShow = page - 2 + i
}
// Ensure page is within bounds and not the first or last page
if (pageToShow > 1 && pageToShow < totalPages) {
return (
<Button
key={pageToShow}
onClick={() => handlePageChange(pageToShow)}
variant={page === pageToShow ? 'default' : 'ghost'}
size="icon"
className={`h-8 w-8 rounded-full ${
page === pageToShow
? 'bg-primary text-brand-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
{pageToShow}
</Button>
)
}
return null
}
)}
{/* Ellipsis if needed */}
{page < totalPages - 3 && totalPages > 7 && (
<span className="px-1">...</span>
)}
{/* Last page */}
{totalPages > 1 && (
<Button
onClick={() => handlePageChange(totalPages)}
variant={page === totalPages ? 'default' : 'ghost'}
size="icon"
className={`h-8 w-8 rounded-full ${
page === totalPages
? 'bg-primary text-brand-foreground hover:bg-primary/90'
: 'hover:bg-secondary'
}`}
>
{totalPages}
</Button>
)}
</div>
<Button
onClick={() =>
handlePageChange(Math.min(totalPages, page + 1))
}
disabled={
page === totalPages || isLoading || isLoadingNotifications
}
variant={page === totalPages ? 'ghost' : 'default'}
>
Next
</Button>
</div>
)}
</div>
</div>
</div>
</div>
)
}
export default WebhooksHistory

View File

@@ -1,7 +1,7 @@
'use client'
import { Button } from '@/components/ui/button'
import { PlusCircle, Webhook } from 'lucide-react'
import { Bell, PlusCircle, Webhook } from 'lucide-react'
import { useState } from 'react'
import { WebhookData } from '@/lib/types'
import { WebhookCard } from './webhook-card'
@@ -18,6 +18,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
function WebhookCardSkeleton() {
return (
@@ -57,8 +59,9 @@ function WebhookCardSkeleton() {
export default function WebhooksSection() {
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const navigator = useRouter()
const [selectedWebhook, setSelectedWebhook] = useState<WebhookData | null>(
null
null,
)
const queryClient = useQueryClient()
@@ -95,31 +98,41 @@ export default function WebhooksSection() {
Manage webhook notifications for your SMS events
</p>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
onClick={handleCreateClick}
disabled={webhooks?.data?.length > 0 || isLoading}
variant='default'
className='w-full sm:w-auto'
>
<PlusCircle className='mr-2 h-4 w-4' />
Create Webhook
</Button>
</div>
</TooltipTrigger>
{webhooks?.data?.length > 0 && (
<TooltipContent>
<p>
You already have an active webhook subscription. You can edit
or manage the existing webhook instead.
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<div className='flex gap-x-4'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
onClick={handleCreateClick}
disabled={webhooks?.data?.length > 0 || isLoading}
variant='default'
className='w-full sm:w-auto'
>
<PlusCircle className='mr-2 h-4 w-4' />
Create Webhook
</Button>
</div>
</TooltipTrigger>
{webhooks?.data?.length > 0 && (
<TooltipContent>
<p>
You already have an active webhook subscription. You can
edit or manage the existing webhook instead.
</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<Button
onClick={() => navigator.push('/dashboard/webhooks')}
variant='default'
className='w-full sm:w-auto'
>
<Bell className='mr-2 h-4 w-4' />
Notification Deliveries
</Button>
</div>
</div>
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-8'>

View File

@@ -0,0 +1,148 @@
'use client'
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnFiltersState,
getFilteredRowModel,
} from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import React from 'react'
import { Skeleton } from '@/components/ui/skeleton'
import { WebhookPayloadModal } from './webhook-payload-modal' // Import your modal
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
searchKey: string
isLoading: boolean
}
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
isLoading,
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [selectedSms, setSelectedSms] = React.useState<any>(null)
const [selectedPayload, setSelectedPayload] = React.useState<any>(null)
const [isModalOpen, setIsModalOpen] = React.useState(false)
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
columnFilters,
},
})
const handleRowClick = (row: any) => {
// Assuming your row data has smsData property
if (row.original.smsData) {
setSelectedSms(row.original.smsData)
setSelectedPayload(row.original.payload)
setIsModalOpen(true)
}
}
const handleCloseModal = () => {
setIsModalOpen(false)
setSelectedSms(null)
setSelectedPayload(null)
}
return (
<div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
{isLoading ? (
<TableBody>
{Array.from({ length: 10 }, (_, index) => (
<TableRow key={index}>
<TableCell colSpan={6}>
<Skeleton className="w-full h-8" />
</TableCell>
</TableRow>
))}
</TableBody>
) : (
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
onClick={() => handleRowClick(row)}
className="cursor-pointer hover:bg-muted/50 transition-colors"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="h-16">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-36 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
)}
</Table>
</div>
{/* Webhook Payload Modal */}
<WebhookPayloadModal
isOpen={isModalOpen}
onClose={handleCloseModal}
smsData={selectedSms}
payload={selectedPayload}
/>
</div>
)
}

View File

@@ -0,0 +1,151 @@
'use client'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
Copy,
} from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
interface SmsData {
_id: string
createdAt: string
device: string
encrypted: boolean
message: string
receivedAt: string
sender: string
status: string
type: string
updatedAt: string
__v: number
}
interface WebhookPayloadModalProps {
isOpen: boolean
onClose: () => void
smsData: SmsData | null
payload?: any
}
export function WebhookPayloadModal({ isOpen, onClose, smsData, payload }: WebhookPayloadModalProps) {
const { toast } = useToast()
if (!smsData) return null
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const copyPayloadToClipboard = async () => {
if (!payload) return
try {
const jsonString = JSON.stringify(payload, null, 2)
await navigator.clipboard.writeText(jsonString)
toast({
title: "Copied!",
description: "Payload copied to clipboard",
duration: 2000,
})
} catch (err) {
console.error('Failed to copy payload:', err)
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = JSON.stringify(payload, null, 2)
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
toast({
title: "Copied!",
description: "Payload copied to clipboard",
duration: 2000,
})
}
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<DialogTitle className="flex items-center gap-2">
Webhook Notification
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{payload && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Payload</h3>
<Button
variant="outline"
size="sm"
onClick={copyPayloadToClipboard}
className="h-8"
>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
</div>
<div className="bg-gray-50 dark:bg-gray-900 rounded-md border p-4 overflow-auto max-h-64">
<pre className="text-xs font-mono whitespace-pre-wrap text-gray-800 dark:text-gray-200">
{JSON.stringify(payload, null, 2)}
</pre>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Sender Information */}
<div className="space-y-2">
<h3 className="font-semibold text-sm flex items-center gap-2">
Sender
</h3>
<p className="text-sm bg-muted/30 rounded-md p-2">
{smsData.sender}
</p>
</div>
{/* Message Type */}
<div className="space-y-2">
<h3 className="font-semibold text-sm">Message Type</h3>
<p className="text-sm bg-muted/30 rounded-md p-2 capitalize">
{smsData.type.toLowerCase()}
</p>
</div>
{/* Status */}
<div className="space-y-2">
<h3 className="font-semibold text-sm">Status</h3>
<p className="text-sm bg-muted/30 rounded-md p-2 capitalize">
{smsData.status}
</p>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-sm flex items-center gap-x-2">
Created At
</h3>
<p className="text-sm bg-muted/30 rounded-md p-2 capitalize">
{new Date(smsData.createdAt).toLocaleDateString('en-GB')}
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { DataTable } from './data-table'
import type { ColumnDef } from '@tanstack/react-table'
import { format } from 'date-fns'
import { Eye } from 'lucide-react'
interface ProductClientProps {
data: ProductColumns[]
}
export type ProductColumns = {
event?: string
deviceName?: string
webhookEvent?: string
deliveryUrl?: string
webhookSubscription?: {
deliveryUrl: string
}
createdAt?: string
status: string
computedStatus?: string
payload?: any
}
export const columns: ColumnDef<ProductColumns>[] = [
{
accessorKey: 'event',
header: 'Event',
},
{
accessorKey: 'deviceName',
header: 'Device',
cell: ({ row }) => {
const deviceName = row.original.deviceName
// If deviceName is an array with two lines
if (Array.isArray(deviceName) && deviceName.length === 2) {
return (
<div className="flex flex-col">
<span className="font-medium">{deviceName[0]}</span>
<span className="text-xs text-muted-foreground">
{deviceName[1]}
</span>
</div>
)
}
// Fallback for single line
return <span>{deviceName}</span>
},
},
{
accessorKey: 'status',
header: 'Status',
},
{
accessorKey: 'webhookSubscriptionData.deliveryUrl',
header: 'Delivery Url',
},
{
accessorKey: 'createdAt',
header: 'Created At',
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<Button variant="ghost" size="sm" disabled={!row.original.payload}>
<Eye className="h-4 w-4" />
</Button>
),
},
]
const formatDate = (dateString: string) => {
return format(new Date(dateString), 'MMM dd, yyyy h:mm a')
}
const ProductClient = ({ data, isLoading, status = 'delivered' }) => {
const { storeId } = useParams()
const router = useRouter()
const formatted = data.map((d) => ({
...d,
deviceName: [
`${d.deviceData.brand} ${d.deviceData.model}`,
` ${d.smsData._id}`,
],
createdAt: formatDate(d.createdAt.toString()),
status: d.computedStatus || status,
payload: d.payload,
}))
return (
<>
<DataTable
searchKey="event"
columns={columns}
data={formatted}
isLoading={isLoading}
/>
</>
)
}
export default ProductClient

View File

@@ -0,0 +1,23 @@
'use client'
import { MessageSquareTextIcon } from 'lucide-react'
import WebhooksHistory from '../(components)/webhooks-history'
export default function MessagingPage() {
return (
<div className="flex-1 p-6 md:p-8">
<div className="space-y-1 mb-6">
<div className="flex items-center space-x-2">
<MessageSquareTextIcon className="h-6 w-6 text-primary" />
<h2 className="text-3xl font-bold tracking-tight">
Webhook Notification Delivery History
</h2>
</div>
</div>
<div className="">
<WebhooksHistory />
</div>
</div>
)
}

View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

120
web/components/ui/table.tsx Normal file
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

@@ -28,6 +28,7 @@ export const ApiEndpoints = {
getMessages: (id: string) => `/gateway/devices/${id}/messages`,
getWebhooks: () => '/webhooks',
getWebhookNotifications: () => '/webhooks/notifications',
createWebhook: () => '/webhooks',
updateWebhook: (id: string) => `/webhooks/${id}`,
getStats: () => '/gateway/stats',

View File

@@ -21,8 +21,10 @@
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.1",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
@@ -31,9 +33,11 @@
"@react-oauth/google": "^0.12.1",
"@sentry/nextjs": "^9",
"@tanstack/react-query": "^5.61.0",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.8.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.453.0",
"next": "14.2.26",
"next-auth": "^4.24.10",
@@ -41,6 +45,7 @@
"papaparse": "^5.4.1",
"prisma": "^5.22.0",
"react": "^18.2.0",
"react-day-picker": "^9.9.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.53.1",

416
web/pnpm-lock.yaml generated
View File

@@ -41,12 +41,18 @@ importers:
'@radix-ui/react-navigation-menu':
specifier: ^1.2.1
version: 1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-scroll-area':
specifier: ^1.2.1
version: 1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-select':
specifier: ^2.1.2
version: 2.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-separator':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-slot':
specifier: ^1.1.0
version: 1.1.0(@types/react@18.2.48)(react@18.2.0)
@@ -71,6 +77,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.61.0
version: 5.61.0(react@18.2.0)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
axios:
specifier: ^1.8.2
version: 1.8.2
@@ -80,6 +89,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
lucide-react:
specifier: ^0.453.0
version: 0.453.0(react@18.2.0)
@@ -101,6 +113,9 @@ importers:
react:
specifier: ^18.2.0
version: 18.2.0
react-day-picker:
specifier: ^9.9.0
version: 9.9.0(react@18.2.0)
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
@@ -247,6 +262,9 @@ packages:
resolution: {integrity: sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==}
engines: {node: '>=6.9.0'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@emnapi/runtime@1.4.3':
resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==}
@@ -771,6 +789,9 @@ packages:
'@radix-ui/primitive@1.1.2':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-accordion@1.2.1':
resolution: {integrity: sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==}
peerDependencies:
@@ -823,6 +844,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-avatar@1.1.1':
resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==}
peerDependencies:
@@ -990,6 +1024,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.3':
resolution: {integrity: sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==}
peerDependencies:
@@ -1025,6 +1072,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-focus-guards@1.1.3':
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-focus-scope@1.1.0':
resolution: {integrity: sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==}
peerDependencies:
@@ -1051,6 +1107,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-focus-scope@1.1.7':
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-icons@1.3.0':
resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==}
peerDependencies:
@@ -1113,6 +1182,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.0':
resolution: {integrity: sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==}
peerDependencies:
@@ -1139,6 +1221,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.2':
resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==}
peerDependencies:
@@ -1165,6 +1260,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.1':
resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==}
peerDependencies:
@@ -1204,6 +1312,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.0.0':
resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==}
peerDependencies:
@@ -1282,6 +1403,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.7':
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.1.0':
resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==}
peerDependencies:
@@ -1370,6 +1504,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.1.0':
resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==}
peerDependencies:
@@ -1406,6 +1549,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.1.1':
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.0':
resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==}
peerDependencies:
@@ -1442,6 +1594,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.0':
resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==}
peerDependencies:
@@ -1451,6 +1612,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.1.0':
resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==}
peerDependencies:
@@ -1480,6 +1650,9 @@ packages:
'@radix-ui/rect@1.1.0':
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@react-oauth/google@0.12.1':
resolution: {integrity: sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==}
peerDependencies:
@@ -1636,6 +1809,17 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@types/connect@3.4.36':
resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==}
@@ -1889,6 +2073,10 @@ packages:
resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==}
engines: {node: '>=10'}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
@@ -2101,6 +2289,12 @@ packages:
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@@ -3239,6 +3433,12 @@ packages:
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
react-day-picker@9.9.0:
resolution: {integrity: sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
react-dom@18.2.0:
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
peerDependencies:
@@ -3308,6 +3508,16 @@ packages:
'@types/react':
optional: true
react-remove-scroll@2.7.1:
resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-style-singleton@2.2.1:
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'}
@@ -3712,6 +3922,16 @@ packages:
'@types/react':
optional: true
use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -3919,6 +4139,8 @@ snapshots:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@date-fns/tz@1.4.1': {}
'@emnapi/runtime@1.4.3':
dependencies:
tslib: 2.8.1
@@ -4467,6 +4689,8 @@ snapshots:
'@radix-ui/primitive@1.1.2': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accordion@1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -4516,6 +4740,15 @@ snapshots:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-avatar@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0)
@@ -4687,6 +4920,19 @@ snapshots:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-dismissable-layer@1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@@ -4721,6 +4967,12 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-focus-guards@1.1.3(@types/react@18.2.48)(react@18.2.0)':
dependencies:
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-focus-scope@1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.48)(react@18.2.0)
@@ -4743,6 +4995,17 @@ snapshots:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-icons@1.3.0(react@18.2.0)':
dependencies:
react: 18.2.0
@@ -4818,6 +5081,29 @@ snapshots:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-slot': 1.2.3(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.48)(react@18.2.0)
aria-hidden: 1.2.6
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.7.1(@types/react@18.2.48)(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-popper@1.2.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -4854,6 +5140,24 @@ snapshots:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-rect': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/rect': 1.1.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-portal@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -4874,6 +5178,16 @@ snapshots:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-portal@1.1.9(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-presence@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.48)(react@18.2.0)
@@ -4904,6 +5218,16 @@ snapshots:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-presence@1.1.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-primitive@2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-slot': 1.1.0(@types/react@18.2.48)(react@18.2.0)
@@ -4994,6 +5318,15 @@ snapshots:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-separator@1.1.7(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
'@radix-ui/react-slot@1.1.0(@types/react@18.2.48)(react@18.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.48)(react@18.2.0)
@@ -5092,6 +5425,12 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.2.48)(react@18.2.0)':
dependencies:
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.2.48)(react@18.2.0)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.48)(react@18.2.0)
@@ -5121,6 +5460,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.2.48)(react@18.2.0)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.2.48)(react@18.2.0)':
dependencies:
react: 18.2.0
@@ -5146,6 +5492,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-use-rect@1.1.1(@types/react@18.2.48)(react@18.2.0)':
dependencies:
'@radix-ui/rect': 1.1.1
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-use-size@1.1.0(@types/react@18.2.48)(react@18.2.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.48)(react@18.2.0)
@@ -5153,6 +5506,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-use-size@1.1.1(@types/react@18.2.48)(react@18.2.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.48)(react@18.2.0)
react: 18.2.0
optionalDependencies:
'@types/react': 18.2.48
'@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -5173,6 +5533,8 @@ snapshots:
'@radix-ui/rect@1.1.0': {}
'@radix-ui/rect@1.1.1': {}
'@react-oauth/google@0.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
react: 18.2.0
@@ -5396,6 +5758,14 @@ snapshots:
'@tanstack/query-core': 5.60.6
react: 18.2.0
'@tanstack/react-table@8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@tanstack/table-core': 8.21.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@tanstack/table-core@8.21.3': {}
'@types/connect@3.4.36':
dependencies:
'@types/node': 20.11.5
@@ -5700,6 +6070,10 @@ snapshots:
dependencies:
tslib: 2.6.2
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
@@ -5930,6 +6304,10 @@ snapshots:
damerau-levenshtein@1.0.8: {}
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
debug@3.2.7:
dependencies:
ms: 2.1.3
@@ -6103,7 +6481,7 @@ snapshots:
'@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
eslint: 8.56.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0))(eslint@8.56.0)
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.56.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.56.0)
eslint-plugin-react: 7.33.2(eslint@8.56.0)
@@ -6122,12 +6500,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0))(eslint@8.56.0):
eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.56.0):
dependencies:
debug: 4.3.4
enhanced-resolve: 5.15.0
eslint: 8.56.0
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0)
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.2
@@ -6139,14 +6517,14 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.8.0(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0):
eslint-module-utils@2.8.0(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
eslint: 8.56.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0))(eslint@8.56.0)
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.56.0)
transitivePeerDependencies:
- supports-color
@@ -6160,7 +6538,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.56.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0)
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0)
hasown: 2.0.0
is-core-module: 2.13.1
is-glob: 4.0.3
@@ -7152,6 +7530,13 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
react-day-picker@9.9.0(react@18.2.0):
dependencies:
'@date-fns/tz': 1.4.1
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 18.2.0
react-dom@18.2.0(react@18.2.0):
dependencies:
loose-envify: 1.4.0
@@ -7215,6 +7600,17 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.48
react-remove-scroll@2.7.1(@types/react@18.2.48)(react@18.2.0):
dependencies:
react: 18.2.0
react-remove-scroll-bar: 2.3.8(@types/react@18.2.48)(react@18.2.0)
react-style-singleton: 2.2.3(@types/react@18.2.48)(react@18.2.0)
tslib: 2.8.1
use-callback-ref: 1.3.3(@types/react@18.2.48)(react@18.2.0)
use-sidecar: 1.1.3(@types/react@18.2.48)(react@18.2.0)
optionalDependencies:
'@types/react': 18.2.48
react-style-singleton@2.2.1(@types/react@18.2.48)(react@18.2.0):
dependencies:
get-nonce: 1.0.1
@@ -7685,6 +8081,14 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.48
use-sidecar@1.1.3(@types/react@18.2.48)(react@18.2.0):
dependencies:
detect-node-es: 1.1.0
react: 18.2.0
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.2.48
util-deprecate@1.0.2: {}
uuid@11.0.3: {}