From c79cee0df725734c42f558076935c71ae72c1932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:55:22 +0300 Subject: [PATCH] =?UTF-8?q?Notification=20Setting=20d=C3=BCzenlemesi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Migrations/LanguagesData.json | 72 ++-- .../ListFormSeeder_Administration.cs | 8 +- .../Migrations/ListFormSeeder_DefaultJsons.cs | 16 +- ui/src/constants/notification-channel.enum.ts | 24 ++ ui/src/services/notification-rule.service.ts | 3 +- .../activityLog/components/LogFilter.tsx | 18 +- .../components/NotificationSettings.tsx | 332 ++++++++++++------ 7 files changed, 307 insertions(+), 166 deletions(-) diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index 5cc2524c..6298bca1 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -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", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs index 3b8a371d..b7613511 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs @@ -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" }), diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs index 858a4767..6833f100 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs @@ -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" }, }), }); diff --git a/ui/src/constants/notification-channel.enum.ts b/ui/src/constants/notification-channel.enum.ts index 527dcf5b..0e8ca236 100644 --- a/ui/src/constants/notification-channel.enum.ts +++ b/ui/src/constants/notification-channel.enum.ts @@ -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 = { 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 = { + Sms: FaSms, + Mail: FaEnvelope, + Rocket: FaRocketchat, + Desktop: FaDesktop, + UiActivity: FaListUl, + UiToast: FaBell, + WhatsApp: FaWhatsapp, + Telegram: FaTelegramPlane, +} + export default NotificationChannels diff --git a/ui/src/services/notification-rule.service.ts b/ui/src/services/notification-rule.service.ts index f467f228..2a3bf333 100644 --- a/ui/src/services/notification-rule.service.ts +++ b/ui/src/services/notification-rule.service.ts @@ -37,6 +37,5 @@ export const postMyNotificationRule = (data: { export const deleteMyNotificationRules = (notificationTypeId: string) => apiService.fetchData({ method: 'DELETE', - url: `/api/app/notification-rule/my-notification-rules`, - params: { notificationTypeId }, + url: `/api/app/notification-rule/my-notification-rules/${notificationTypeId}`, }) diff --git a/ui/src/views/admin/activityLog/components/LogFilter.tsx b/ui/src/views/admin/activityLog/components/LogFilter.tsx index ed26cdf6..0da5a0f2 100644 --- a/ui/src/views/admin/activityLog/components/LogFilter.tsx +++ b/ui/src/views/admin/activityLog/components/LogFilter.tsx @@ -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 = ({ {translate('::Abp.Identity.ActivityLogs.Channels')} - {ticketCheckboxes.map((checkbox) => ( - - {checkbox.label} + {channels.map((channel) => ( + + {translate(`::App.NotificationChannel.${channel}`)} ))} diff --git a/ui/src/views/admin/profile/components/NotificationSettings.tsx b/ui/src/views/admin/profile/components/NotificationSettings.tsx index 7a3ec183..1d826f6f 100644 --- a/ui/src/views/admin/profile/components/NotificationSettings.tsx +++ b/ui/src/views/admin/profile/components/NotificationSettings.tsx @@ -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 ( + + {Icon && } + + ) +} + const NotificationSettings = () => { const { translate } = useLocalization() const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [list, setList] = useState([]) + const [rules, setRules] = useState>({}) const [notificationTypes, setNotificationTypes] = useState([]) + // Yalnızca kaydedilen hücre/satır kilitlenir; matrisin tamamını kilitlemek tek tıkta ekranı donduruyordu. + const [pending, setPending] = useState([]) 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) => { + 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 ( + + + + + + ) } + + return ( + onToggle(!rule.isActive, rule)} + /> + ) + } + + const renderResetButton = (notificationTypeId: string) => ( + +