Claude güncellemesi Notification güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-11 13:00:43 +03:00
parent 42d502f812
commit 3ecb4e2102
9 changed files with 178 additions and 216 deletions

View file

@ -41,10 +41,15 @@ public override partial NotificationRuleDto Map(NotificationRule source);
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public partial class NotificationToNotificationDtoMapper : MapperBase<Notification, NotificationDto> public partial class NotificationToNotificationDtoMapper : MapperBase<Notification, NotificationDto>
{ {
[MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))] // NotificationType hedefte string. Kaynaktaki navigation entity'si doğrudan
// eşlenirse ToString() devreye girip "[ENTITY: NotificationType] Id = ..."
// yazısı UI'a düşüyor; bu yüzden açıkça Name alanından beslenir.
[MapProperty("NotificationType.Name", nameof(NotificationDto.NotificationType))]
[MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))]
[MapperIgnoreTarget(nameof(NotificationDto.TenantId))] [MapperIgnoreTarget(nameof(NotificationDto.TenantId))]
public override partial NotificationDto Map(Notification source); public override partial NotificationDto Map(Notification source);
[MapProperty("NotificationType.Name", nameof(NotificationDto.NotificationType))]
[MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))] [MapperIgnoreTarget(nameof(NotificationDto.CreatorFullname))]
[MapperIgnoreTarget(nameof(NotificationDto.TenantId))] [MapperIgnoreTarget(nameof(NotificationDto.TenantId))]
public override partial void Map(Notification source, NotificationDto destination); public override partial void Map(Notification source, NotificationDto destination);

View file

@ -279,4 +279,20 @@ public static class LookupQueryValues
$"WHERE \"SkillTypeId\" IN (SELECT \"Id\" FROM \"{FullNameTable(TableNameEnum.SkillType)}\" WHERE \"Name\"='Technical Skills' ) " + $"WHERE \"SkillTypeId\" IN (SELECT \"Id\" FROM \"{FullNameTable(TableNameEnum.SkillType)}\" WHERE \"Name\"='Technical Skills' ) " +
$"ORDER BY \"Name\""; $"ORDER BY \"Name\"";
public static string NotificationIdentityValues =
$"SELECT " +
$"\"Id\" AS \"Key\", " +
$"\"Name\" AS \"Name\" " +
$"FROM \"AbpRoles\"" +
$"UNION ALL " +
$"SELECT " +
$"\"Id\" AS \"Key\", " +
$"\"UserName\" AS \"Name\" " +
$"FROM \"AbpUsers\"" +
$"UNION ALL " +
$"SELECT " +
$"\"Id\" AS \"Key\", " +
$"\"DisplayName\" AS \"Name\" " +
$"FROM \"AbpOrganizationUnits\"" +
$"ORDER BY \"Name\"";
} }

View file

@ -5585,7 +5585,12 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
ListOrderNo = 5, ListOrderNo = 5,
Visible = true, Visible = true,
IsActive = true, IsActive = true,
LookupJson = JsonSerializer.Serialize(new LookupDto {
DataSourceType = UiLookupDataSourceTypeEnum.Query,
DisplayExpr = "Name",
ValueExpr = "Key",
LookupQuery = LookupQueryValues.NotificationIdentityValues
}),
AllowSearch = true, AllowSearch = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson, ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name), PermissionJson = DefaultFieldPermissionJson(listForm.Name),

View file

@ -5,7 +5,7 @@ import Dropdown from '@/components/ui/Dropdown'
import ScrollBar from '@/components/ui/ScrollBar' import ScrollBar from '@/components/ui/ScrollBar'
import Spinner from '@/components/ui/Spinner' import Spinner from '@/components/ui/Spinner'
import Tooltip from '@/components/ui/Tooltip' import Tooltip from '@/components/ui/Tooltip'
import { AVATAR_URL } from '@/constants/app.constant' import { APP_NAME, AVATAR_URL } from '@/constants/app.constant'
import NotificationChannels from '@/constants/notification-channel.enum' import NotificationChannels from '@/constants/notification-channel.enum'
import { ROUTES_ENUM } from '@/routes/route.constant' import { ROUTES_ENUM } from '@/routes/route.constant'
import { import {
@ -26,9 +26,9 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { FaEnvelopeOpen } from 'react-icons/fa' import { FaEnvelopeOpen } from 'react-icons/fa'
import { FcAdvertising } from 'react-icons/fc'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Notification as Notify, toast } from '../ui' import { Notification as Notify, toast } from '../ui'
import { FcAdvertising } from 'react-icons/fc'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
@ -45,10 +45,6 @@ type NotificationList = {
const notificationHeight = 'h-72' const notificationHeight = 'h-72'
const notificationInterval = 120000 // 2 minutes const notificationInterval = 120000 // 2 minutes
const notificationTypeAvatar = (creatorId: string, tenantId?: string) => {
return <Avatar shape="circle" src={AVATAR_URL(creatorId, tenantId)} />
}
const NotificationToggle = ({ const NotificationToggle = ({
className, className,
unreadCount, unreadCount,
@ -78,12 +74,12 @@ const _Notification = ({ className }: { className?: string }) => {
const [notificationList, setNotificationList] = useState<NotificationList[]>([]) const [notificationList, setNotificationList] = useState<NotificationList[]>([])
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0) const [unreadNotificationCount, setUnreadNotificationCount] = useState(0)
const [noResult, setNoResult] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const toastNotificationList = useRef<string[]>([]) // Aynı bildirimin sekme ömrü boyunca ikinci kez toast/desktop olarak
const desktopNotificationList = useRef<string[]>([]) // gösterilmesini engeller.
const pushNotificationInterval = useRef<ReturnType<typeof setInterval>>() const pushedIds = useRef(new Set<string>())
const listLoaded = useRef(false)
const { bgTheme } = useThemeClass() const { bgTheme } = useThemeClass()
const { larger } = useResponsive() const { larger } = useResponsive()
@ -95,7 +91,7 @@ const _Notification = ({ className }: { className?: string }) => {
tabHasFocusRef.current = tabHasFocus tabHasFocusRef.current = tabHasFocus
}, [tabHasFocus]) }, [tabHasFocus])
const getReactNotificationCount = useCallback(async () => { const getUnreadCount = useCallback(async () => {
const resp = await getList({ const resp = await getList({
channels: [NotificationChannels.UiActivity], channels: [NotificationChannels.UiActivity],
isListRequest: false, isListRequest: false,
@ -103,85 +99,9 @@ const _Notification = ({ className }: { className?: string }) => {
maxResultCount: 1, maxResultCount: 1,
}) })
setUnreadNotificationCount(resp.data?.totalCount ?? 0) setUnreadNotificationCount(resp.data?.totalCount ?? 0)
}, [setUnreadNotificationCount]) }, [])
useEffect(() => { const getPushNotifications = useCallback(async () => {
getReactNotificationCount()
var intervalId = setInterval(() => {
if (tabHasFocusRef.current) {
getReactNotificationCount()
}
}, notificationInterval)
return () => {
clearInterval(intervalId)
}
}, [getReactNotificationCount])
const onNotificationOpen = useCallback(async () => {
const currentUnread = notificationList.filter((a) => !a.readed).length
if (currentUnread !== unreadNotificationCount) {
setLoading(true)
const resp = await getList({
channels: [NotificationChannels.UiActivity],
isListRequest: false,
maxResultCount: 1000,
})
const items = resp.data.items ?? []
if (items.length > 0) {
await updateSentMany(items.map((notification) => notification.id), true)
}
const newNotificationList = items.map(
(a) =>
({
id: a.id,
notificationType: a.notificationType,
date: a.creationTime.toLocaleString(),
message: a.message,
creatorId: a.creatorId,
tenantId: a.tenantId,
readed: a.isRead,
}) as NotificationList,
)
setLoading(false)
setNotificationList(newNotificationList)
setNoResult(newNotificationList.length == 0)
}
}, [notificationList, setLoading, unreadNotificationCount])
const onMarkAllAsRead = useCallback(async () => {
await updateReadAll(NotificationChannels.UiToast, true)
const list = notificationList.map((item: NotificationList) => {
if (!item.readed) {
item.readed = true
}
return item
})
setNotificationList(list)
setUnreadNotificationCount(0)
}, [notificationList])
const onMarkAsRead = useCallback(
async (id: string) => {
await updateRead(id, true)
const list = notificationList.map((item) => {
if (item.id === id) {
item.readed = true
}
return item
})
setNotificationList(list)
const unreadCount = notificationList.filter((item) => !item.readed).length
setUnreadNotificationCount(unreadCount)
},
[notificationList],
)
const getPushNotifications = async () => {
const desktopGranted = const desktopGranted =
'Notification' in window && window.Notification.permission === 'granted' 'Notification' in window && window.Notification.permission === 'granted'
@ -194,84 +114,130 @@ const _Notification = ({ className }: { className?: string }) => {
isSent: false, isSent: false,
maxResultCount: 1000, maxResultCount: 1000,
}) })
const items = resp.data.items ?? []
// Toast const pending = (resp.data.items ?? []).filter(
const newToastList = items.filter( (a) => !a.isSent && !pushedIds.current.has(a.id),
(a) =>
a.notificationChannel === NotificationChannels.UiToast &&
!toastNotificationList.current.includes(a.id) &&
!a.isSent,
) )
toastNotificationList.current = [ if (pending.length === 0) {
...toastNotificationList.current, return
...newToastList.map((a) => a.id),
]
for (const notification of newToastList) {
toast.push(
<Notify type="success" duration={0} closable={true}>
{notification.message}
</Notify>,
{ placement: 'bottom-end' },
)
} }
pending.forEach((a) => pushedIds.current.add(a.id))
// Desktop for (const notification of pending) {
const newDesktopList = desktopGranted if (notification.notificationChannel === NotificationChannels.UiToast) {
? items.filter( toast.push(
(a) => <Notify type="success" duration={0} closable={true}>
a.notificationChannel === NotificationChannels.Desktop && {notification.message}
!desktopNotificationList.current.includes(a.id) && </Notify>,
!a.isSent, { placement: 'bottom-end' },
) )
: [] continue
if (desktopGranted) { }
desktopNotificationList.current = [
...desktopNotificationList.current,
...newDesktopList.map((a) => a.id),
]
for (const notification of newDesktopList) {
const title = notification.notificationType || 'Bildirim'
const options = {
body: notification.message,
dir: 'ltr',
requireInteraction: true,
} as NotificationOptions
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { const title = notification.notificationType || APP_NAME
const reg = await navigator.serviceWorker.ready const options: NotificationOptions = {
await reg.showNotification(title, options) body: notification.message,
} else { dir: 'ltr',
new window.Notification(title, options) requireInteraction: true,
} }
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
const reg = await navigator.serviceWorker.ready
await reg.showNotification(title, options)
} else {
new window.Notification(title, options)
} }
} }
const processedIds = [...newToastList, ...newDesktopList].map((notification) => notification.id) const processedIds = pending.map((a) => a.id)
if (processedIds.length > 0) { await updateSentMany(processedIds, true)
await updateSentMany(processedIds, true) await updateReadMany(processedIds, true)
await updateReadMany(processedIds, true) }, [])
}
}
useEffect(() => { useEffect(() => {
if ('Notification' in window && window.Notification.permission === 'default') { if ('Notification' in window && window.Notification.permission === 'default') {
window.Notification.requestPermission() window.Notification.requestPermission()
} }
getPushNotifications() const poll = () => {
getUnreadCount()
getPushNotifications()
}
pushNotificationInterval.current = setInterval(() => { poll()
const intervalId = setInterval(() => {
if (tabHasFocusRef.current) { if (tabHasFocusRef.current) {
getPushNotifications() poll()
} }
}, notificationInterval) }, notificationInterval)
return () => { return () => clearInterval(intervalId)
clearInterval(pushNotificationInterval.current) }, [getUnreadCount, getPushNotifications])
const onNotificationOpen = useCallback(async () => {
const currentUnread = notificationList.filter((a) => !a.readed).length
if (listLoaded.current && currentUnread === unreadNotificationCount) {
return
} }
setLoading(true)
try {
const resp = await getList({
channels: [NotificationChannels.UiActivity],
isListRequest: false,
maxResultCount: 1000,
})
const items = resp.data.items ?? []
if (items.length > 0) {
await updateSentMany(
items.map((notification) => notification.id),
true,
)
}
setNotificationList(
items.map((a) => ({
id: a.id,
notificationType: a.notificationType,
date: a.creationTime.toLocaleString(),
message: a.message,
creatorId: a.creatorId,
tenantId: a.tenantId,
readed: a.isRead,
})) as NotificationList[],
)
listLoaded.current = true
} finally {
setLoading(false)
}
}, [notificationList, unreadNotificationCount])
const onMarkAllAsRead = useCallback(async () => {
await updateReadAll(NotificationChannels.UiActivity, true)
setNotificationList((list) => list.map((item) => ({ ...item, readed: true })))
setUnreadNotificationCount(0)
}, []) }, [])
const onMarkAsRead = useCallback(
async (id: string) => {
const target = notificationList.find((item) => item.id === id)
if (!target || target.readed) {
return
}
await updateRead(id, true)
setNotificationList((list) =>
list.map((item) => (item.id === id ? { ...item, readed: true } : item)),
)
setUnreadNotificationCount((count) => Math.max(0, count - 1))
},
[notificationList],
)
const noResult = !loading && listLoaded.current && notificationList.length === 0
return ( return (
<Tooltip title={translate('::App.Notifications')}> <Tooltip title={translate('::App.Notifications')}>
<Dropdown <Dropdown
@ -298,33 +264,34 @@ const _Notification = ({ className }: { className?: string }) => {
</Dropdown.Item> </Dropdown.Item>
<div className={classNames('overflow-y-auto', notificationHeight)}> <div className={classNames('overflow-y-auto', notificationHeight)}>
<ScrollBar direction={direction}> <ScrollBar direction={direction}>
{notificationList.length > 0 && {notificationList.map((item, index) => (
notificationList.map((item, index) => ( <div
<div key={item.id}
key={item.id} className={`relative flex px-4 py-4 cursor-pointer hover:bg-gray-50 active:bg-gray-100 dark:hover:bg-black dark:hover:bg-opacity-20 ${
className={`relative flex px-4 py-4 cursor-pointer hover:bg-gray-50 active:bg-gray-100 dark:hover:bg-black dark:hover:bg-opacity-20 ${ !isLastChild(notificationList, index)
!isLastChild(notificationList, index) ? 'border-b border-gray-200 dark:border-gray-600'
? 'border-b border-gray-200 dark:border-gray-600' : ''
: '' }`}
}`} onClick={() => onMarkAsRead(item.id)}
onClick={() => onMarkAsRead(item.id)} >
> <div>
<div>{notificationTypeAvatar(item.creatorId, item.tenantId)}</div> <Avatar shape="circle" src={AVATAR_URL(item.creatorId, item.tenantId)} />
<div className="ltr:ml-3 rtl:mr-3">
<div>
{item.notificationType && (
<span className="font-semibold heading-text">{item.notificationType} </span>
)}
<div>{item.message}</div>
</div>
<span className="text-xs">{dayjs(item.date).fromNow()}</span>
</div>
<Badge
className="absolute top-4 ltr:right-4 rtl:left-4 mt-1.5"
innerClass={`${item.readed ? 'bg-gray-300' : bgTheme} `}
/>
</div> </div>
))} <div className="ltr:ml-3 rtl:mr-3">
<div>
{item.notificationType && (
<span className="font-semibold heading-text">{item.notificationType} </span>
)}
<div>{item.message}</div>
</div>
<span className="text-xs">{dayjs(item.date).fromNow()}</span>
</div>
<Badge
className="absolute top-4 ltr:right-4 rtl:left-4 mt-1.5"
innerClass={`${item.readed ? 'bg-gray-300' : bgTheme} `}
/>
</div>
))}
{loading && ( {loading && (
<div className={classNames('flex items-center justify-center', notificationHeight)}> <div className={classNames('flex items-center justify-center', notificationHeight)}>
<Spinner size={40} /> <Spinner size={40} />

View file

@ -3,7 +3,6 @@ import ConfigProvider from '@/components/ui/ConfigProvider'
import { themeConfig } from '@/proxy/theme/theme.config' import { themeConfig } from '@/proxy/theme/theme.config'
import { useStoreActions, useStoreState } from '@/store' import { useStoreActions, useStoreState } from '@/store'
import useDarkMode from '@/utils/hooks/useDarkmode' import useDarkMode from '@/utils/hooks/useDarkmode'
import useNotification from '@/utils/hooks/useNotification'
import { useSetting } from '@/utils/hooks/useSetting' import { useSetting } from '@/utils/hooks/useSetting'
import useTabFocus from '@/utils/hooks/useTabFocus' import useTabFocus from '@/utils/hooks/useTabFocus'
import { ComponentProps, useEffect } from 'react' import { ComponentProps, useEffect } from 'react'
@ -74,7 +73,6 @@ const Theme = (props: CommonProps) => {
'en' 'en'
useDarkMode() useDarkMode()
useTabFocus() useTabFocus()
useNotification()
useAppVersionNotice() useAppVersionNotice()
type ConfigProviderValue = NonNullable<ComponentProps<typeof ConfigProvider>['value']> type ConfigProviderValue = NonNullable<ComponentProps<typeof ConfigProvider>['value']>

View file

@ -34,7 +34,9 @@ const Notification = forwardRef<HTMLDivElement, NotificationProps>(
...rest ...rest
} = props } = props
const [display, setDisplay] = useState('show') // Toast tarafından yönetilmeyen bildirimler kapatma animasyonu
// tamamlandıktan sonra kendilerini DOM'dan kaldırır.
const [hidden, setHidden] = useState(false)
const { clear } = useTimeout( const { clear } = useTimeout(
onClose as () => void, onClose as () => void,
@ -44,12 +46,11 @@ const Notification = forwardRef<HTMLDivElement, NotificationProps>(
const handleClose = useCallback( const handleClose = useCallback(
(e: MouseEvent<HTMLSpanElement>) => { (e: MouseEvent<HTMLSpanElement>) => {
setDisplay('hiding')
onClose?.(e) onClose?.(e)
clear() clear()
if (!triggerByToast) { if (!triggerByToast) {
setTimeout(() => { setTimeout(() => {
setDisplay('hide') setHidden(true)
}, 400) }, 400)
} }
}, },
@ -58,7 +59,7 @@ const Notification = forwardRef<HTMLDivElement, NotificationProps>(
const notificationClass = classNames('notification', className) const notificationClass = classNames('notification', className)
if (display === 'hide') { if (hidden) {
return null return null
} }

View file

@ -29,13 +29,6 @@ export const updateReadAll = (notificationChannel: string, isRead: boolean) =>
params: { notificationChannel, isRead }, params: { notificationChannel, isRead },
}) })
export const updateSent = (notificationId: string, isSent: boolean) =>
apiService.fetchData<NotificationDto>({
method: 'PUT',
url: `/api/app/notification/sent/${notificationId}`,
params: { isSent },
})
export const updateReadMany = (notificationIds: string[], isRead: boolean) => export const updateReadMany = (notificationIds: string[], isRead: boolean) =>
apiService.fetchData<number, string[]>({ apiService.fetchData<number, string[]>({
method: 'PUT', method: 'PUT',

View file

@ -1,17 +0,0 @@
import { useEffect } from 'react'
function useNotification() {
useEffect(() => {
if (window === undefined) {
return
}
if (!('Notification' in window)) {
console.log('This browser does not support desktop notification')
} else {
Notification.requestPermission()
}
}, [])
}
export default useNotification

View file

@ -12,7 +12,7 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
import { Field, Form, Formik, FormikHelpers } from 'formik' import { Field, Form, Formik, FormikHelpers } from 'formik'
import * as Yup from 'yup' import * as Yup from 'yup'
export interface NotificationDto { interface CreateNotificationValues {
id: string id: string
message: string message: string
} }
@ -34,34 +34,28 @@ function CreateNotification({
const { translate } = useLocalization() const { translate } = useLocalization()
const handleSubmit = async ( const handleSubmit = async (
values: NotificationDto, values: CreateNotificationValues,
{ setSubmitting }: FormikHelpers<NotificationDto>, { setSubmitting }: FormikHelpers<CreateNotificationValues>,
) => { ) => {
if (!id) { if (!id) {
return return
} }
setSubmitting(true)
try { try {
await postMyNotificationByNotificationRuleId({ id, message: values.message }) await postMyNotificationByNotificationRuleId({ id, message: values.message })
toast.push( toast.push(
<Notification type="success" duration={2000}> <Notification type="success" duration={2000}>
{translate('::Kaydet')} {translate('::App.Platform.Success')}
</Notification>, </Notification>,
{ { placement: 'bottom-end' },
placement: 'bottom-end',
},
) )
onDialogClose() onDialogClose()
} catch { } catch {
toast.push( toast.push(
<Notification type="danger" duration={2000}> <Notification type="danger" duration={2000}>
{'Hata'} {translate('::App.Platform.Error')}
</Notification>, </Notification>,
{ { placement: 'bottom-end' },
placement: 'bottom-end',
},
) )
} finally { } finally {
setSubmitting(false) setSubmitting(false)