feat: notification settings per role + review/unread-msg notifications
- Add notificationSettings JSONB to role_permissions (migration 089)
- sendPushForNotification() — pushes only to users with the notif type enabled
- reviews.ts — push + in-app on new direct and QR reviews
- room-service.ts — use sendPushForNotification('room_service_order')
- publicWidget.ts — push + in-app on new online booking
- jobs.ts — runUnreadMessagesJob() every 15 min, deduped by in-process set
- UsersPage: NOTIFICATION_GROUPS UI in roles tab with per-type toggles
- api.ts / RolePermissionsContext: notificationSettings in types and save()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
4
backend/migrations/089_notification_settings.sql
Normal file
4
backend/migrations/089_notification_settings.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Migration 089 — Notification settings per role
|
||||
|
||||
ALTER TABLE role_permissions
|
||||
ADD COLUMN IF NOT EXISTS notification_settings JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -3,10 +3,12 @@ import { broadcast } from './routes/ws'
|
||||
import { createNotification } from './routes/notifications'
|
||||
import { getHkSettings } from './routes/housekeeping-settings'
|
||||
import { sendReviewRequestEmail } from './email'
|
||||
import { sendPushForNotification } from './push'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour
|
||||
const PAYMENT_EXPIRY_INTERVAL = 2 * 60 * 1000 // every 2 minutes
|
||||
const UNREAD_MSG_INTERVAL = 15 * 60 * 1000 // every 15 minutes
|
||||
|
||||
async function runAutoJobs(): Promise<void> {
|
||||
try {
|
||||
@@ -302,6 +304,86 @@ async function runReviewRequestJob(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Track which rooms we already notified (in-process, resets on restart — acceptable)
|
||||
const notifiedRooms = new Set<string>()
|
||||
|
||||
async function runUnreadMessagesJob(): Promise<void> {
|
||||
try {
|
||||
// Find chat rooms that have unread messages older than 15 minutes
|
||||
// (messages sent by guests/external, not yet read by any hotel user)
|
||||
const { rows } = await db.query<{
|
||||
hotel_id: string; hotel_slug: string; room_id: string; room_name: string; unread_count: number
|
||||
}>(`
|
||||
SELECT
|
||||
h.id AS hotel_id,
|
||||
h.slug AS hotel_slug,
|
||||
cr.id AS room_id,
|
||||
COALESCE(cr.name, 'Чат') AS room_name,
|
||||
COUNT(cm.id) AS unread_count
|
||||
FROM chat_messages cm
|
||||
JOIN chat_rooms cr ON cr.id = cm.room_id
|
||||
JOIN hotels h ON h.id = cr.hotel_id
|
||||
WHERE cm.created_at < NOW() - interval '15 minutes'
|
||||
AND cm.sender_type IN ('guest', 'system')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM chat_message_reads cmr
|
||||
WHERE cmr.message_id = cm.id
|
||||
AND cmr.user_id IN (
|
||||
SELECT id FROM users WHERE hotel_id = h.id
|
||||
)
|
||||
)
|
||||
GROUP BY h.id, h.slug, cr.id, cr.name
|
||||
HAVING COUNT(cm.id) > 0
|
||||
`)
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `${row.hotel_id}:${row.room_id}`
|
||||
if (notifiedRooms.has(key)) continue
|
||||
notifiedRooms.add(key)
|
||||
|
||||
const count = Number(row.unread_count)
|
||||
const body = `${count} непрочитанн${count === 1 ? 'ое сообщение' : count < 5 ? 'ых сообщения' : 'ых сообщений'} более 15 минут`
|
||||
|
||||
void createNotification(row.hotel_id, row.hotel_slug, {
|
||||
type: 'unread_messages',
|
||||
title: `Непрочитанные сообщения — ${row.room_name}`,
|
||||
body,
|
||||
link: `/${row.hotel_slug}/chat`,
|
||||
}).catch(() => {})
|
||||
|
||||
void sendPushForNotification(row.hotel_id, 'unread_messages', {
|
||||
title: `Непрочитанные сообщения — ${row.room_name}`,
|
||||
body,
|
||||
url: `/${row.hotel_slug}/chat`,
|
||||
tag: `unread-${row.room_id}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Clear keys for rooms that have been read (so next cycle they can re-trigger if new unread appear)
|
||||
for (const key of notifiedRooms) {
|
||||
const [hotelId, roomId] = key.split(':')
|
||||
const { rows: unread } = await db.query<{ count: string }>(
|
||||
`SELECT COUNT(cm.id) AS count
|
||||
FROM chat_messages cm
|
||||
JOIN hotels h ON h.id = $1
|
||||
WHERE cm.room_id = $2
|
||||
AND cm.sender_type IN ('guest', 'system')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM chat_message_reads cmr
|
||||
WHERE cmr.message_id = cm.id
|
||||
AND cmr.user_id IN (SELECT id FROM users WHERE hotel_id = $1)
|
||||
)`,
|
||||
[hotelId, roomId],
|
||||
)
|
||||
if (Number(unread[0]?.count ?? 0) === 0) {
|
||||
notifiedRooms.delete(key)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[jobs] unread messages job error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function startJobs(): void {
|
||||
// Small delay to let DB migrations finish on startup
|
||||
setTimeout(() => {
|
||||
@@ -316,4 +398,8 @@ export function startJobs(): void {
|
||||
setInterval(() => {
|
||||
runPaymentExpiryJob().catch(console.error)
|
||||
}, PAYMENT_EXPIRY_INTERVAL)
|
||||
|
||||
setInterval(() => {
|
||||
runUnreadMessagesJob().catch(console.error)
|
||||
}, UNREAD_MSG_INTERVAL)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,41 @@ export async function sendPushToUser(userId: string, payload: PushPayload): Prom
|
||||
} catch { /**/ }
|
||||
}
|
||||
|
||||
// Send push to hotel users whose role has the given notification type enabled.
|
||||
// If notification_settings is empty / key missing → defaults to true (send).
|
||||
export async function sendPushForNotification(
|
||||
hotelId: string,
|
||||
notificationType: string,
|
||||
payload: PushPayload,
|
||||
): Promise<void> {
|
||||
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return
|
||||
try {
|
||||
const { rows } = await db.query(
|
||||
`SELECT ps.endpoint, ps.p256dh, ps.auth
|
||||
FROM push_subscriptions ps
|
||||
JOIN users u ON u.id = ps.user_id
|
||||
LEFT JOIN role_permissions rp
|
||||
ON rp.hotel_id = u.hotel_id AND rp.role_key = u.role
|
||||
WHERE u.hotel_id = $1
|
||||
AND COALESCE((rp.notification_settings->>$2)::boolean, true) = true`,
|
||||
[hotelId, notificationType],
|
||||
)
|
||||
for (const sub of rows) {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
JSON.stringify(payload),
|
||||
)
|
||||
} catch (err) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint])
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /**/ }
|
||||
}
|
||||
|
||||
export async function sendPushToHotel(hotelId: string, payload: PushPayload): Promise<void> {
|
||||
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { db } from '../db'
|
||||
import { getGatewayForModule } from './paymentGateways'
|
||||
import { createCharge } from '../services/yookassa'
|
||||
import { upsertGuestFromBooking } from '../services/guestUpsert'
|
||||
import { createNotification } from './notifications'
|
||||
import { sendPushForNotification } from '../push'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; bookingId: string } }
|
||||
@@ -466,6 +468,22 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
||||
guestPhone,
|
||||
})
|
||||
|
||||
// Notify staff about new widget booking
|
||||
const nights = Math.ceil((new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
|
||||
void createNotification(hotel.id, hotel.slug, {
|
||||
type: 'new_booking',
|
||||
title: `Новая бронь через сайт — ${guestName}`,
|
||||
body: `${checkIn} — ${checkOut}, ${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}`,
|
||||
bookingId,
|
||||
link: `/${hotel.slug}/bookings`,
|
||||
})
|
||||
void sendPushForNotification(hotel.id, 'new_booking', {
|
||||
title: `Новая бронь через сайт — ${guestName}`,
|
||||
body: `${checkIn} → ${checkOut}`,
|
||||
url: `/${hotel.slug}/bookings`,
|
||||
tag: `booking-${bookingId}`,
|
||||
})
|
||||
|
||||
if (paymentMethod === 'yookassa') {
|
||||
// Create YooKassa payment
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { sendReviewReplyEmail } from '../email'
|
||||
import { createNotification } from './notifications'
|
||||
import { sendPushForNotification } from '../push'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
@@ -253,6 +255,21 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
],
|
||||
)
|
||||
|
||||
const hotelSlugRow = await db.query<{ slug: string }>('SELECT slug FROM hotels WHERE id = $1', [booking.hotel_id])
|
||||
const hotelSlug = hotelSlugRow.rows[0]?.slug ?? ''
|
||||
void createNotification(booking.hotel_id, hotelSlug, {
|
||||
type: 'new_review',
|
||||
title: `Новый отзыв — ${rating}★`,
|
||||
body: `${booking.guest_name ?? 'Гость'}${text ? ': ' + text.slice(0, 80) : ''}`,
|
||||
link: `/${hotelSlug}/reviews`,
|
||||
})
|
||||
void sendPushForNotification(booking.hotel_id, 'new_review', {
|
||||
title: `Новый отзыв — ${rating}★`,
|
||||
body: `${booking.guest_name ?? 'Гость'}${text ? ': ' + text.slice(0, 80) : ''}`,
|
||||
url: `/${hotelSlug}/reviews`,
|
||||
tag: `review-${booking.id}`,
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
@@ -340,6 +357,19 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
],
|
||||
)
|
||||
|
||||
void createNotification(hotel.id, slug, {
|
||||
type: 'new_review',
|
||||
title: `Новый QR-отзыв — №${room}, ${rating}★`,
|
||||
body: text ? text.slice(0, 80) : `Оценка: ${rating} из 5`,
|
||||
link: `/${slug}/reviews`,
|
||||
})
|
||||
void sendPushForNotification(hotel.id, 'new_review', {
|
||||
title: `Новый QR-отзыв — №${room}, ${rating}★`,
|
||||
body: text ? text.slice(0, 80) : `Оценка: ${rating} из 5`,
|
||||
url: `/${slug}/reviews`,
|
||||
tag: `review-qr-${hotel.id}-${room}`,
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -36,21 +36,22 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
[hotelId],
|
||||
)
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
hotelId: r.hotel_id,
|
||||
roleKey: r.role_key,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
isSystem: r.is_system,
|
||||
permissions: r.permissions,
|
||||
homePage: r.home_page,
|
||||
id: r.id,
|
||||
hotelId: r.hotel_id,
|
||||
roleKey: r.role_key,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
isSystem: r.is_system,
|
||||
permissions: r.permissions,
|
||||
homePage: r.home_page,
|
||||
notificationSettings: r.notification_settings ?? {},
|
||||
}))
|
||||
},
|
||||
)
|
||||
|
||||
// ── PUT /api/hotels/:slug/role-permissions/:roleKey ────────────────────────
|
||||
fastify.put<SlugRoleParam & {
|
||||
Body: { name: string; color: string; isSystem?: boolean; permissions: Record<string, boolean>; homePage?: string }
|
||||
Body: { name: string; color: string; isSystem?: boolean; permissions: Record<string, boolean>; homePage?: string; notificationSettings?: Record<string, boolean> }
|
||||
}>(
|
||||
'/api/hotels/:slug/role-permissions/:roleKey',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -65,30 +66,32 @@ const rolePermissionsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { name, color, isSystem = false, permissions, homePage = null } = request.body
|
||||
const { name, color, isSystem = false, permissions, homePage = null, notificationSettings = {} } = request.body
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions, home_page)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`INSERT INTO role_permissions (hotel_id, role_key, name, color, is_system, permissions, home_page, notification_settings)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (hotel_id, role_key) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
color = EXCLUDED.color,
|
||||
permissions = EXCLUDED.permissions,
|
||||
home_page = EXCLUDED.home_page,
|
||||
notification_settings = EXCLUDED.notification_settings,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions), homePage],
|
||||
[hotelId, roleKey, name, color, isSystem, JSON.stringify(permissions), homePage, JSON.stringify(notificationSettings)],
|
||||
)
|
||||
const r = rows[0]
|
||||
return {
|
||||
id: r.id,
|
||||
hotelId: r.hotel_id,
|
||||
roleKey: r.role_key,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
isSystem: r.is_system,
|
||||
permissions: r.permissions,
|
||||
homePage: r.home_page,
|
||||
id: r.id,
|
||||
hotelId: r.hotel_id,
|
||||
roleKey: r.role_key,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
isSystem: r.is_system,
|
||||
permissions: r.permissions,
|
||||
homePage: r.home_page,
|
||||
notificationSettings: r.notification_settings ?? {},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { createNotification } from './notifications'
|
||||
import { sendPushToHotel } from '../push'
|
||||
import { sendPushForNotification } from '../push'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugItemParam = { Params: { slug: string; itemId: string } }
|
||||
@@ -364,8 +364,8 @@ const roomServiceRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
link: '/room-service',
|
||||
})
|
||||
|
||||
// Push notification to all hotel staff
|
||||
void sendPushToHotel(hotel.id, {
|
||||
// Push notification to staff with room_service_order enabled
|
||||
void sendPushForNotification(hotel.id, 'room_service_order', {
|
||||
title: `Новый заказ Room Service — №${room_number}`,
|
||||
body: itemsSummary,
|
||||
url: '/room-service',
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface SavedRolePermission {
|
||||
isSystem: boolean
|
||||
permissions: Record<string, boolean>
|
||||
homePage?: string | null
|
||||
notificationSettings?: Record<string, boolean>
|
||||
}
|
||||
|
||||
interface RolePermissionsContextValue {
|
||||
|
||||
@@ -313,7 +313,7 @@ export const api = {
|
||||
'GET', `/api/hotels/${slug}/role-permissions`),
|
||||
|
||||
save: (slug: string, roleKey: string, data: {
|
||||
name: string; color: string; isSystem: boolean; permissions: Record<string, boolean>; homePage?: string | null
|
||||
name: string; color: string; isSystem: boolean; permissions: Record<string, boolean>; homePage?: string | null; notificationSettings?: Record<string, boolean>
|
||||
}) =>
|
||||
req<import('../contexts/RolePermissionsContext').SavedRolePermission>(
|
||||
'PUT', `/api/hotels/${slug}/role-permissions/${roleKey}`, data),
|
||||
|
||||
@@ -42,6 +42,7 @@ interface RolePermissions {
|
||||
isSystem: boolean
|
||||
permissions: Record<string, boolean>
|
||||
homePage?: string | null
|
||||
notificationSettings: Record<string, boolean>
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
@@ -135,6 +136,28 @@ const ALL_MODULE_KEYS = MODULE_GROUPS.flatMap(g => g.modules.map(m => m.key))
|
||||
const allPerms = (v: boolean) =>
|
||||
Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v]))
|
||||
|
||||
const NOTIFICATION_GROUPS: { group: string; items: { key: string; label: string; description: string }[] }[] = [
|
||||
{
|
||||
group: 'Операции',
|
||||
items: [
|
||||
{ key: 'room_service_order', label: 'Новый заказ Room Service', description: 'Уведомление о новом заказе в номер' },
|
||||
{ key: 'new_booking', label: 'Новая бронь через сайт', description: 'Бронирование через онлайн-виджет' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Общение',
|
||||
items: [
|
||||
{ key: 'unread_messages', label: 'Непрочитанные сообщения', description: 'Чат без ответа более 15 минут' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Отзывы',
|
||||
items: [
|
||||
{ key: 'new_review', label: 'Новый отзыв гостя', description: 'Прямой отзыв, QR или email-ссылка' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Permission key → route path (for home page selector)
|
||||
const PERM_TO_ROUTE: Record<string, string> = {
|
||||
calendar: '/calendar',
|
||||
@@ -172,6 +195,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
color: '#7C3AED',
|
||||
isSystem: true,
|
||||
permissions: allPerms(true),
|
||||
notificationSettings: {},
|
||||
},
|
||||
{
|
||||
id: 'rp_manager',
|
||||
@@ -179,6 +203,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
color: '#4F46E5',
|
||||
isSystem: true,
|
||||
permissions: allPerms(true),
|
||||
notificationSettings: {},
|
||||
},
|
||||
{
|
||||
id: 'rp_receptionist',
|
||||
@@ -192,6 +217,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
pos: true, reviews: true, reports: true,
|
||||
documents: true, website: true,
|
||||
},
|
||||
notificationSettings: {},
|
||||
},
|
||||
{
|
||||
id: 'rp_housekeeper',
|
||||
@@ -202,6 +228,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
...allPerms(false),
|
||||
calendar: true, housekeeping: true, rooms: true, maintenance: true,
|
||||
},
|
||||
notificationSettings: {},
|
||||
},
|
||||
{
|
||||
id: 'rp_accountant',
|
||||
@@ -214,6 +241,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true,
|
||||
documents: true,
|
||||
},
|
||||
notificationSettings: {},
|
||||
},
|
||||
{
|
||||
id: 'rp_security',
|
||||
@@ -224,6 +252,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
...allPerms(false),
|
||||
calendar: true, bookings: true,
|
||||
},
|
||||
notificationSettings: {},
|
||||
},
|
||||
{
|
||||
id: 'rp_technician',
|
||||
@@ -236,6 +265,7 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
||||
housekeeping: true, rooms: true, maintenance: true,
|
||||
floor_map: true, equipment: true, ttlock: true,
|
||||
},
|
||||
notificationSettings: {},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -553,7 +583,7 @@ function RolesTab() {
|
||||
if (!s) return def
|
||||
// Ensure all module keys are present (new modules default to false)
|
||||
const fullPerms = { ...allPerms(false), ...s.permissions }
|
||||
return { ...def, permissions: fullPerms, homePage: s.homePage ?? def.homePage }
|
||||
return { ...def, permissions: fullPerms, homePage: s.homePage ?? def.homePage, notificationSettings: s.notificationSettings ?? {} }
|
||||
})
|
||||
|
||||
// Custom roles (not in system list)
|
||||
@@ -567,6 +597,7 @@ function RolesTab() {
|
||||
isSystem: false,
|
||||
permissions: { ...allPerms(false), ...r.permissions },
|
||||
homePage: r.homePage ?? null,
|
||||
notificationSettings: r.notificationSettings ?? {},
|
||||
}))
|
||||
|
||||
setRoles([...merged, ...custom])
|
||||
@@ -602,6 +633,14 @@ function RolesTab() {
|
||||
))
|
||||
}
|
||||
|
||||
const toggleNotif = (key: string) => {
|
||||
setRoles(prev => prev.map(r => {
|
||||
if (r.id !== selectedRoleId) return r
|
||||
const current = r.notificationSettings[key] ?? true
|
||||
return { ...r, notificationSettings: { ...r.notificationSettings, [key]: !current } }
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Save to API ────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
if (!slug || saving) return
|
||||
@@ -615,6 +654,7 @@ function RolesTab() {
|
||||
isSystem: role.isSystem,
|
||||
permissions: role.permissions,
|
||||
homePage: role.homePage ?? null,
|
||||
notificationSettings: role.notificationSettings,
|
||||
})
|
||||
}))
|
||||
setSaveOk(true)
|
||||
@@ -639,6 +679,7 @@ function RolesTab() {
|
||||
color: colors[roles.length % colors.length],
|
||||
isSystem: false,
|
||||
permissions: allPerms(false),
|
||||
notificationSettings: {},
|
||||
}
|
||||
setRoles(prev => [...prev, newRole])
|
||||
setSelectedRoleId(newRole.id)
|
||||
@@ -653,6 +694,7 @@ function RolesTab() {
|
||||
isSystem: false,
|
||||
permissions: newRole.permissions,
|
||||
homePage: null,
|
||||
notificationSettings: {},
|
||||
})
|
||||
await reloadContext()
|
||||
} catch (err) {
|
||||
@@ -902,6 +944,65 @@ function RolesTab() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Notification settings */}
|
||||
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-700 pt-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">
|
||||
Настройки уведомлений
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-3">
|
||||
Какие push и in-app уведомления получают сотрудники с этой ролью
|
||||
</p>
|
||||
</div>
|
||||
{NOTIFICATION_GROUPS.map(group => (
|
||||
<div key={group.group}>
|
||||
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-2">
|
||||
{group.group}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{group.items.map(item => {
|
||||
const enabled = selectedRole.notificationSettings[item.key] ?? true
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => toggleNotif(item.key)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 rounded-xl border text-left transition-all',
|
||||
enabled
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'
|
||||
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 mt-0.5 transition-colors',
|
||||
enabled
|
||||
? 'bg-emerald-600 border-emerald-600'
|
||||
: 'border-slate-300 dark:border-slate-600',
|
||||
)}>
|
||||
{enabled && <Check size={11} className="text-white" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn(
|
||||
'text-sm font-medium',
|
||||
enabled
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-slate-700 dark:text-slate-300',
|
||||
)}>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedRole.isSystem && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||
|
||||
Reference in New Issue
Block a user