Notification Setting düzenlemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-09-23 09:55:22 +03:00
parent 368756f0ac
commit c79cee0df7
7 changed files with 307 additions and 166 deletions

View file

@ -1929,20 +1929,20 @@
{
"resourceName": "Platform",
"key": "App.NotificationChannel.Sms",
"en": "SMS",
"tr": "SMS"
"en": "Sms",
"tr": "Sms"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.Mail",
"en": "E-mail",
"en": "Mail",
"tr": "E-posta"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.Rocket",
"en": "Rocket.Chat",
"tr": "Rocket.Chat"
"en": "Rocket",
"tr": "Rocket"
},
{
"resourceName": "Platform",
@ -1953,14 +1953,14 @@
{
"resourceName": "Platform",
"key": "App.NotificationChannel.UiActivity",
"en": "Activity",
"tr": "Etkinlik"
"en": "UI Activity",
"tr": "Arayüz Etkinliği"
},
{
"resourceName": "Platform",
"key": "App.NotificationChannel.UiToast",
"en": "In-app",
"tr": "Uygulama içi"
"en": "UI Toast",
"tr": "Arayüz Bildirimi"
},
{
"resourceName": "Platform",
@ -1980,6 +1980,30 @@
"en": "Reset to default",
"tr": "Varsayılana döndür"
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.Description",
"en": "Choose how you want to be notified for each notification type.",
"tr": "Her bildirim türü için nasıl haberdar olmak istediğinizi seçin."
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.Type",
"en": "Notification type",
"tr": "Bildirim türü"
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.Customized",
"en": "Customized by you",
"tr": "Sizin tarafınızdan değiştirildi"
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.Fixed",
"en": "Fixed by the administrator",
"tr": "Yönetici tarafından sabitlenmiş"
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.NoRule",
@ -19578,12 +19602,6 @@
"en": "DELETE",
"tr": "DELETE"
},
{
"resourceName": "Platform",
"key": "App.StaticLookup.Desktop",
"en": "Desktop",
"tr": "Masaüstü"
},
{
"resourceName": "Platform",
"key": "App.StaticLookup.Double",
@ -19674,12 +19692,6 @@
"en": "List",
"tr": "Liste"
},
{
"resourceName": "Platform",
"key": "App.StaticLookup.Mail",
"en": "Mail",
"tr": "E-posta"
},
{
"resourceName": "Platform",
"key": "App.StaticLookup.MailQueueWorker",
@ -19914,12 +19926,6 @@
"en": "Reference",
"tr": "Referans"
},
{
"resourceName": "Platform",
"key": "App.StaticLookup.Rocket",
"en": "Rocket",
"tr": "Rocket"
},
{
"resourceName": "Platform",
"key": "App.StaticLookup.Role",
@ -20028,12 +20034,6 @@
"en": "Time",
"tr": "Saat"
},
{
"resourceName": "Platform",
"key": "App.StaticLookup.UiActivity",
"en": "UI Activity",
"tr": "Arayüz Etkinliği"
},
{
"resourceName": "Platform",
"key": "App.StaticLookup.UInt16",
@ -20052,12 +20052,6 @@
"en": "UInt64",
"tr": "UInt64"
},
{
"resourceName": "Platform",
"key": "App.StaticLookup.UiToast",
"en": "UI Toast",
"tr": "Arayüz Bildirimi"
},
{
"resourceName": "Platform",
"key": "App.Platform.User",

View file

@ -235,7 +235,7 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
SeedSyncInsert = true,
SeedSyncUpdate = true,
SeedSyncDelete = true,
SortMode = GridOptions.SortModeSingle,
SortMode = GridOptions.SortModeMultiple,
FilterRowJson = DefaultFilterRowJson(),
HeaderFilterJson = DefaultHeaderFilterJson(),
SearchPanelJson = DefaultSearchPanelJson(),
@ -454,6 +454,8 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
Visible = true,
IsActive = true,
SortIndex = 2,
SortDirection = GridColumnOptions.SortOrderAsc,
LookupJson = JsonSerializer.Serialize(new LookupDto
{
DataSourceType = UiLookupDataSourceTypeEnum.Query,
@ -7238,8 +7240,8 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
}),
CommandColumnJson = JsonSerializer.Serialize(new CommandColumnDto[] {
new CommandColumnDto() {
Hint = "Create",
Text = "Create",
Hint = "App.Platform.Create",
Text = "App.Platform.Create",
AuthName = listFormName,
DialogName = "CreateNotification",
DialogParameters = JsonSerializer.Serialize(new { name = "id", id = "@Id" }),

View file

@ -64,14 +64,14 @@ public static class ListFormSeeder_DefaultJsons
ValueExpr = "key",
LookupQuery = JsonSerializer.Serialize(new LookupDataDto[]
{
new() { Key = "Sms", Name = "App.Sender.Sms" },
new() { Key = "Mail", Name = "App.StaticLookup.Mail" },
new() { Key = "Rocket", Name = "App.StaticLookup.Rocket" },
new() { Key = "Desktop", Name = "App.StaticLookup.Desktop" },
new() { Key = "UiActivity", Name = "App.StaticLookup.UiActivity" },
new() { Key = "UiToast", Name = "App.StaticLookup.UiToast" },
new() { Key = "WhatsApp", Name = "App.Sender.WhatsApp" },
new() { Key = "Telegram", Name = "App.Sender.Telegram" },
new() { Key = "Sms", Name = "App.NotificationChannel.Sms" },
new() { Key = "Mail", Name = "App.NotificationChannel.Mail" },
new() { Key = "Rocket", Name = "App.NotificationChannel.Rocket" },
new() { Key = "Desktop", Name = "App.NotificationChannel.Desktop" },
new() { Key = "UiActivity", Name = "App.NotificationChannel.UiActivity" },
new() { Key = "UiToast", Name = "App.NotificationChannel.UiToast" },
new() { Key = "WhatsApp", Name = "App.NotificationChannel.WhatsApp" },
new() { Key = "Telegram", Name = "App.NotificationChannel.Telegram" },
}),
});

View file

@ -1,3 +1,15 @@
import type { IconType } from 'react-icons'
import {
FaBell,
FaDesktop,
FaEnvelope,
FaListUl,
FaRocketchat,
FaSms,
FaTelegramPlane,
FaWhatsapp,
} from 'react-icons/fa'
const NotificationChannels = {
Sms: 'Sms',
Mail: 'Mail',
@ -20,4 +32,16 @@ export const NotificationChannelColors: Record<string, string> = {
Telegram: 'bg-purple-900',
}
/** Kanal kayıt defterinin görsel yüzü; yeni kanal eklendiğinde ikon da burada tanımlanır. */
export const NotificationChannelIcons: Record<string, IconType> = {
Sms: FaSms,
Mail: FaEnvelope,
Rocket: FaRocketchat,
Desktop: FaDesktop,
UiActivity: FaListUl,
UiToast: FaBell,
WhatsApp: FaWhatsapp,
Telegram: FaTelegramPlane,
}
export default NotificationChannels

View file

@ -37,6 +37,5 @@ export const postMyNotificationRule = (data: {
export const deleteMyNotificationRules = (notificationTypeId: string) =>
apiService.fetchData<NotificationRuleDto[]>({
method: 'DELETE',
url: `/api/app/notification-rule/my-notification-rules`,
params: { notificationTypeId },
url: `/api/app/notification-rule/my-notification-rules/${notificationTypeId}`,
})

View file

@ -7,16 +7,8 @@ import classNames from 'classnames'
type CategoryTitleProps = CommonProps
const ticketCheckboxes = [
{ label: NotificationChannels.Sms, value: NotificationChannels.Sms },
{ label: NotificationChannels.Mail, value: NotificationChannels.Mail },
{ label: NotificationChannels.Rocket, value: NotificationChannels.Rocket },
{ label: NotificationChannels.Desktop, value: NotificationChannels.Desktop },
{ label: NotificationChannels.UiActivity, value: NotificationChannels.UiActivity },
{ label: NotificationChannels.UiToast, value: NotificationChannels.UiToast },
{ label: NotificationChannels.WhatsApp, value: NotificationChannels.WhatsApp },
{ label: NotificationChannels.Telegram, value: NotificationChannels.Telegram },
]
/** Kanal listesi kayit defterinden gelir; etiket her ekranda ayni dil anahtarindan okunur. */
const channels = Object.values(NotificationChannels)
const CategoryTitle = ({ children, className }: CategoryTitleProps) => {
return (
@ -64,9 +56,9 @@ const LogFilter = ({
<CategoryTitle className="mb-3 text-gray-500 dark:text-gray-400">
{translate('::Abp.Identity.ActivityLogs.Channels')}
</CategoryTitle>
{ticketCheckboxes.map((checkbox) => (
<Checkbox key={checkbox.value} className="mb-3 dark:text-white" value={checkbox.value}>
{checkbox.label}
{channels.map((channel) => (
<Checkbox key={channel} className="mb-3 dark:text-white" value={channel}>
{translate(`::App.NotificationChannel.${channel}`)}
</Checkbox>
))}
</Checkbox.Group>

View file

@ -1,5 +1,8 @@
import { Button, Checkbox, Spinner, Table } from '@/components/ui'
import NotificationChannels from '@/constants/notification-channel.enum'
import { Button, Spinner, Switcher, Tooltip } from '@/components/ui'
import NotificationChannels, {
NotificationChannelColors,
NotificationChannelIcons,
} from '@/constants/notification-channel.enum'
import { NotificationRuleDto, NotificationTypeDto } from '@/proxy/notification/models'
import {
deleteMyNotificationRules,
@ -8,49 +11,64 @@ import {
postMyNotificationRule,
} from '@/services/notification-rule.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import classNames from 'classnames'
import { useCallback, useEffect, useState } from 'react'
import { FaHistory } from 'react-icons/fa'
const { Tr, Th, Td, THead, TBody } = Table
import type { MouseEvent } from 'react'
import { FaHistory, FaLock } from 'react-icons/fa'
/** Matris sütunları kanal kayıt defterinden gelir; yeni bir kanal eklendiğinde ekran kendiliğinden güncel olur. */
const channels = Object.values(NotificationChannels)
const ruleKey = (notificationTypeId: string, channel: string) => `${notificationTypeId}|${channel}`
/** Sunucuda karşılığı olmayan tür/kanal kesişimi kapalı sayılır; matris boşluk bırakmaz. */
const emptyRule = (notificationTypeId: string, channel: string): NotificationRuleDto => ({
id: '',
notificationTypeId,
channel,
isActive: false,
isFixed: false,
isCustomized: false,
})
const ChannelIcon = ({ channel, size = 'md' }: { channel: string; size?: 'sm' | 'md' }) => {
const Icon = NotificationChannelIcons[channel]
return (
<span
className={classNames(
'flex items-center justify-center rounded-full text-white',
size === 'sm' ? 'h-6 w-6 text-xs' : 'h-7 w-7 text-sm',
NotificationChannelColors[channel] ?? 'bg-gray-400',
)}
>
{Icon && <Icon />}
</span>
)
}
const NotificationSettings = () => {
const { translate } = useLocalization()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [list, setList] = useState<NotificationRuleDto[][]>([])
const [rules, setRules] = useState<Record<string, NotificationRuleDto>>({})
const [notificationTypes, setNotificationTypes] = useState<NotificationTypeDto[]>([])
// Yalnızca kaydedilen hücre/satır kilitlenir; matrisin tamamını kilitlemek tek tıkta ekranı donduruyordu.
const [pending, setPending] = useState<string[]>([])
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [typesResponse, rulesResponse] = await Promise.all([
getMyNotificationTypes(),
getMyNotificationRules(),
])
const types = typesResponse.data ?? []
const rules = rulesResponse.data ?? []
setNotificationTypes(types)
setList(
types.map((type) =>
channels.map(
(channel) =>
rules.find(
(rule) => rule.channel === channel && rule.notificationTypeId === type.id,
) ?? {
id: '',
notificationTypeId: type.id,
channel,
isActive: false,
isFixed: false,
isCustomized: false,
},
),
setNotificationTypes(typesResponse.data ?? [])
setRules(
Object.fromEntries(
(rulesResponse.data ?? []).map((rule) => [
ruleKey(rule.notificationTypeId, rule.channel),
rule,
]),
),
)
} finally {
@ -63,94 +81,206 @@ const NotificationSettings = () => {
fetchData()
}, [fetchData])
const onClickCheckbox = async (checked: boolean, rule: NotificationRuleDto) => {
// Kayıt sürerken kutular kilitlenir; art arda tıklamada son dönen yanıt kazanır ve
// ekran sunucudaki gerçeğin tersine düşerdi.
setSaving(true)
const getRule = (notificationTypeId: string, channel: string) =>
rules[ruleKey(notificationTypeId, channel)] ?? emptyRule(notificationTypeId, channel)
const isPending = (key: string) => pending.includes(key)
const withPending = async (key: string, action: () => Promise<unknown>) => {
setPending((current) => [...current, key])
try {
await postMyNotificationRule({
channel: rule.channel,
notificationTypeId: rule.notificationTypeId,
isActive: checked,
})
await fetchData()
await action()
} finally {
setSaving(false)
setPending((current) => current.filter((item) => item !== key))
}
}
const resetMyNotifications = async (notificationTypeId: string) => {
setSaving(true)
try {
const onToggle = (checked: boolean, rule: NotificationRuleDto) => {
const key = ruleKey(rule.notificationTypeId, rule.channel)
// Anahtar hemen döner, sunucu yanıtından sonra gerçek durum geri yazılır; hata olursa eski değere döner.
setRules((current) => ({
...current,
[key]: { ...rule, isActive: checked, isCustomized: true },
}))
return withPending(key, async () => {
try {
await postMyNotificationRule({
channel: rule.channel,
notificationTypeId: rule.notificationTypeId,
isActive: checked,
})
await fetchData()
} catch {
setRules((current) => ({ ...current, [key]: rule }))
}
})
}
const resetMyNotifications = (notificationTypeId: string) =>
withPending(notificationTypeId, async () => {
await deleteMyNotificationRules(notificationTypeId)
await fetchData()
} finally {
setSaving(false)
})
const isCustomized = (notificationTypeId: string) =>
channels.some((channel) => getRule(notificationTypeId, channel).isCustomized)
const renderSwitcher = (notificationTypeId: string, channel: string) => {
const rule = getRule(notificationTypeId, channel)
const busy = isPending(ruleKey(notificationTypeId, channel)) || isPending(notificationTypeId)
if (rule.isFixed) {
return (
<Tooltip title={translate('::Abp.Identity.NotificationSettings.Fixed')}>
<span className={rule.isActive ? 'text-emerald-600' : 'text-gray-400'}>
<FaLock />
</span>
</Tooltip>
)
}
return (
<Switcher
checked={rule.isActive}
isLoading={busy}
disabled={busy}
// Switcher denetimli kipte eski degeri geri veriyor; yeni deger kuraldan turetilir.
onChange={() => onToggle(!rule.isActive, rule)}
/>
)
}
const renderResetButton = (notificationTypeId: string) => (
<Tooltip title={translate('::Abp.Identity.NotificationSettings.Reset')}>
<Button
shape="circle"
variant="plain"
type="button"
size="xs"
disabled={!isCustomized(notificationTypeId) || isPending(notificationTypeId)}
icon={<FaHistory />}
onClick={(e: MouseEvent) => {
e.preventDefault()
resetMyNotifications(notificationTypeId)
}}
/>
</Tooltip>
)
const renderTypeTitle = (type: NotificationTypeDto) => (
<>
<div className="flex items-center gap-2">
<span className="font-semibold">{type.name}</span>
{isCustomized(type.id) && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" />}
</div>
{type.description && <p className="mt-0.5 text-xs text-gray-500">{type.description}</p>}
</>
)
if (loading) {
return (
<div className="flex items-center justify-center py-10">
<Spinner size={40} />
</div>
)
}
if (!notificationTypes.length) {
return (
<p className="py-10 text-center text-gray-500">
{translate('::Abp.Identity.NotificationSettings.NoRule')}
</p>
)
}
return (
<>
{loading ? (
<div className="flex items-center justify-center">
<Spinner size={40} />
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-2">
<p className="text-sm text-gray-500">
{translate('::Abp.Identity.NotificationSettings.Description')}
</p>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500" />
{translate('::Abp.Identity.NotificationSettings.Customized')}
</span>
<span className="flex items-center gap-1.5">
<FaLock />
{translate('::Abp.Identity.NotificationSettings.Fixed')}
</span>
</div>
) : (
<>
{list.length ? (
<Table compact={true}>
<THead>
<Tr>
<Th></Th>
{channels.map((channel) => (
<Th key={channel} className="text-center">
</div>
{/* Geniş ekranda matris, dar ekranda kart listesi: sekiz kanal dar ekranda tek satıra sığmıyor. */}
<div className="hidden overflow-x-auto rounded-xl border border-gray-200 md:block dark:border-gray-700">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-semibold dark:bg-gray-700">
{translate('::Abp.Identity.NotificationSettings.Type')}
</th>
{channels.map((channel) => (
<th key={channel} className="px-3 py-3 font-semibold">
<span className="flex flex-col items-center gap-1.5">
<ChannelIcon channel={channel} />
<span className="text-xs whitespace-nowrap">
{translate(`::App.NotificationChannel.${channel}`)}
</Th>
))}
</Tr>
</THead>
<TBody>
{notificationTypes.map((type, m) => (
<Tr key={type.id}>
<Th>
<Button
shape="circle"
variant="plain"
type="button"
size="sm"
disabled={saving}
title={translate('::Abp.Identity.NotificationSettings.Reset')}
icon={<FaHistory />}
onClick={async (e) => {
e.preventDefault()
resetMyNotifications(type.id)
}}
/>
<span title={type.description ?? undefined}>{type.name}</span>
</Th>
{channels.map((channel, n) => (
<Td key={type.id + channel} className="text-center">
<Checkbox
name={type.id + channel}
disabled={list[m][n].isFixed || saving}
checked={list[m][n].isActive}
color={list[m][n].isCustomized ? 'red-500' : undefined}
onChange={(e) => onClickCheckbox(e, list[m][n])}
></Checkbox>
</Td>
))}
</Tr>
</span>
</span>
</th>
))}
<th className="w-12 px-2 py-3" />
</tr>
</thead>
<tbody>
{notificationTypes.map((type) => (
<tr
key={type.id}
className="border-t border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700"
>
<td className="sticky left-0 z-10 bg-white px-4 py-3 dark:bg-gray-800">
{renderTypeTitle(type)}
</td>
{channels.map((channel) => (
<td key={channel} className="px-3 py-3">
<div className="flex justify-center">{renderSwitcher(type.id, channel)}</div>
</td>
))}
</TBody>
</Table>
) : (
<p className="text-center">
{translate('::Abp.Identity.NotificationSettings.NoRule')}
</p>
)}
</>
)}
</>
<td className="px-2 py-3 text-center">{renderResetButton(type.id)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 md:hidden">
{notificationTypes.map((type) => (
<div key={type.id} className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-start justify-between gap-2">
<div>{renderTypeTitle(type)}</div>
{renderResetButton(type.id)}
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{channels.map((channel) => (
<div
key={channel}
className="flex items-center justify-between gap-3 rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-700"
>
<span className="flex items-center gap-2">
<ChannelIcon channel={channel} size="sm" />
<span className="text-sm">
{translate(`::App.NotificationChannel.${channel}`)}
</span>
</span>
{renderSwitcher(type.id, channel)}
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}