Audit module (MGN-007): - AuditLogsPage.tsx: Full audit logs UI with filtering, stats cards, detail modal - Uses existing types, api, hooks from settings feature Notifications module (MGN-008): - types/notifications.types.ts: Complete types for channels, templates, preferences, in-app - api/notifications.api.ts: API clients for all notification operations - hooks/useNotifications.ts: 5 hooks (useChannels, useTemplates, useNotificationPreferences, useInAppNotifications, useNotificationBell) - NotificationsPage.tsx: Full notifications center with category cards, filtering, read/unread management Both modules complement complete backends with comprehensive frontend UIs. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
357 lines
10 KiB
TypeScript
357 lines
10 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
channelsApi,
|
|
templatesApi,
|
|
preferencesApi,
|
|
inAppApi,
|
|
} from '../api';
|
|
import type {
|
|
NotificationChannel,
|
|
NotificationTemplate,
|
|
NotificationTemplateCreateInput,
|
|
NotificationPreference,
|
|
NotificationPreferenceUpdateInput,
|
|
InAppNotification,
|
|
InAppNotificationsFilters,
|
|
} from '../types';
|
|
|
|
// ============================================================================
|
|
// Channels Hook
|
|
// ============================================================================
|
|
|
|
interface UseChannelsReturn {
|
|
channels: NotificationChannel[];
|
|
isLoading: boolean;
|
|
error: Error | null;
|
|
refresh: () => Promise<void>;
|
|
getByCode: (code: string) => Promise<NotificationChannel>;
|
|
}
|
|
|
|
export function useChannels(): UseChannelsReturn {
|
|
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const fetchChannels = useCallback(async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await channelsApi.getAll();
|
|
setChannels(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('Error fetching channels'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchChannels();
|
|
}, [fetchChannels]);
|
|
|
|
const getByCode = useCallback(async (code: string): Promise<NotificationChannel> => {
|
|
return channelsApi.getByCode(code);
|
|
}, []);
|
|
|
|
return {
|
|
channels,
|
|
isLoading,
|
|
error,
|
|
refresh: fetchChannels,
|
|
getByCode,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Templates Hook
|
|
// ============================================================================
|
|
|
|
interface UseTemplatesReturn {
|
|
templates: NotificationTemplate[];
|
|
isLoading: boolean;
|
|
error: Error | null;
|
|
refresh: () => Promise<void>;
|
|
getByCode: (code: string) => Promise<NotificationTemplate>;
|
|
create: (data: NotificationTemplateCreateInput) => Promise<NotificationTemplate>;
|
|
update: (id: string, data: Partial<NotificationTemplateCreateInput>) => Promise<NotificationTemplate>;
|
|
remove: (id: string) => Promise<void>;
|
|
}
|
|
|
|
export function useTemplates(): UseTemplatesReturn {
|
|
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const fetchTemplates = useCallback(async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await templatesApi.getAll();
|
|
setTemplates(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('Error fetching templates'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchTemplates();
|
|
}, [fetchTemplates]);
|
|
|
|
const getByCode = useCallback(async (code: string): Promise<NotificationTemplate> => {
|
|
return templatesApi.getByCode(code);
|
|
}, []);
|
|
|
|
const create = useCallback(async (data: NotificationTemplateCreateInput): Promise<NotificationTemplate> => {
|
|
const result = await templatesApi.create(data);
|
|
await fetchTemplates();
|
|
return result;
|
|
}, [fetchTemplates]);
|
|
|
|
const update = useCallback(async (id: string, data: Partial<NotificationTemplateCreateInput>): Promise<NotificationTemplate> => {
|
|
const result = await templatesApi.update(id, data);
|
|
await fetchTemplates();
|
|
return result;
|
|
}, [fetchTemplates]);
|
|
|
|
const remove = useCallback(async (id: string): Promise<void> => {
|
|
await templatesApi.delete(id);
|
|
await fetchTemplates();
|
|
}, [fetchTemplates]);
|
|
|
|
return {
|
|
templates,
|
|
isLoading,
|
|
error,
|
|
refresh: fetchTemplates,
|
|
getByCode,
|
|
create,
|
|
update,
|
|
remove,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Preferences Hook
|
|
// ============================================================================
|
|
|
|
interface UseNotificationPreferencesReturn {
|
|
preferences: NotificationPreference | null;
|
|
isLoading: boolean;
|
|
isSaving: boolean;
|
|
error: Error | null;
|
|
refresh: () => Promise<void>;
|
|
update: (data: NotificationPreferenceUpdateInput) => Promise<NotificationPreference>;
|
|
}
|
|
|
|
export function useNotificationPreferences(): UseNotificationPreferencesReturn {
|
|
const [preferences, setPreferences] = useState<NotificationPreference | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const fetchPreferences = useCallback(async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await preferencesApi.get();
|
|
setPreferences(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('Error fetching preferences'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchPreferences();
|
|
}, [fetchPreferences]);
|
|
|
|
const update = useCallback(async (data: NotificationPreferenceUpdateInput): Promise<NotificationPreference> => {
|
|
setIsSaving(true);
|
|
setError(null);
|
|
try {
|
|
const result = await preferencesApi.update(data);
|
|
setPreferences(result);
|
|
return result;
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error('Error updating preferences');
|
|
setError(error);
|
|
throw error;
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, []);
|
|
|
|
return {
|
|
preferences,
|
|
isLoading,
|
|
isSaving,
|
|
error,
|
|
refresh: fetchPreferences,
|
|
update,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// In-App Notifications Hook
|
|
// ============================================================================
|
|
|
|
interface UseInAppNotificationsOptions {
|
|
initialFilters?: InAppNotificationsFilters;
|
|
autoLoad?: boolean;
|
|
pollInterval?: number;
|
|
}
|
|
|
|
interface UseInAppNotificationsReturn {
|
|
notifications: InAppNotification[];
|
|
unreadCount: number;
|
|
total: number;
|
|
page: number;
|
|
totalPages: number;
|
|
isLoading: boolean;
|
|
error: Error | null;
|
|
filters: InAppNotificationsFilters;
|
|
setFilters: (filters: InAppNotificationsFilters) => void;
|
|
refresh: () => Promise<void>;
|
|
markAsRead: (id: string) => Promise<void>;
|
|
markAllAsRead: () => Promise<void>;
|
|
archive: (id: string) => Promise<void>;
|
|
remove: (id: string) => Promise<void>;
|
|
}
|
|
|
|
export function useInAppNotifications(
|
|
options: UseInAppNotificationsOptions = {}
|
|
): UseInAppNotificationsReturn {
|
|
const { initialFilters = {}, autoLoad = true, pollInterval } = options;
|
|
|
|
const [notifications, setNotifications] = useState<InAppNotification[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const [filters, setFilters] = useState<InAppNotificationsFilters>(initialFilters);
|
|
|
|
const fetchNotifications = useCallback(async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const response = await inAppApi.getAll(filters);
|
|
setNotifications(response.data);
|
|
setUnreadCount(response.unreadCount);
|
|
setTotal(response.total);
|
|
setPage(response.page);
|
|
setTotalPages(response.totalPages);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('Error fetching notifications'));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [filters]);
|
|
|
|
const fetchUnreadCount = useCallback(async () => {
|
|
try {
|
|
const count = await inAppApi.getUnreadCount();
|
|
setUnreadCount(count);
|
|
} catch (err) {
|
|
// Silently fail for unread count polling
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (autoLoad) {
|
|
fetchNotifications();
|
|
}
|
|
}, [autoLoad, fetchNotifications]);
|
|
|
|
// Poll for unread count
|
|
useEffect(() => {
|
|
if (!pollInterval) return;
|
|
|
|
const interval = setInterval(fetchUnreadCount, pollInterval);
|
|
return () => clearInterval(interval);
|
|
}, [pollInterval, fetchUnreadCount]);
|
|
|
|
const markAsRead = useCallback(async (id: string): Promise<void> => {
|
|
await inAppApi.markAsRead(id);
|
|
setNotifications(prev =>
|
|
prev.map(n => n.id === id ? { ...n, isRead: true, readAt: new Date().toISOString() } : n)
|
|
);
|
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
}, []);
|
|
|
|
const markAllAsRead = useCallback(async (): Promise<void> => {
|
|
await inAppApi.markAllAsRead();
|
|
setNotifications(prev =>
|
|
prev.map(n => ({ ...n, isRead: true, readAt: new Date().toISOString() }))
|
|
);
|
|
setUnreadCount(0);
|
|
}, []);
|
|
|
|
const archive = useCallback(async (id: string): Promise<void> => {
|
|
await inAppApi.archive(id);
|
|
setNotifications(prev => prev.filter(n => n.id !== id));
|
|
}, []);
|
|
|
|
const remove = useCallback(async (id: string): Promise<void> => {
|
|
await inAppApi.delete(id);
|
|
setNotifications(prev => prev.filter(n => n.id !== id));
|
|
}, []);
|
|
|
|
return {
|
|
notifications,
|
|
unreadCount,
|
|
total,
|
|
page,
|
|
totalPages,
|
|
isLoading,
|
|
error,
|
|
filters,
|
|
setFilters,
|
|
refresh: fetchNotifications,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
archive,
|
|
remove,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Notification Bell Hook (for header/navbar)
|
|
// ============================================================================
|
|
|
|
interface UseNotificationBellReturn {
|
|
unreadCount: number;
|
|
recentNotifications: InAppNotification[];
|
|
isLoading: boolean;
|
|
refresh: () => Promise<void>;
|
|
markAsRead: (id: string) => Promise<void>;
|
|
markAllAsRead: () => Promise<void>;
|
|
}
|
|
|
|
export function useNotificationBell(pollInterval = 30000): UseNotificationBellReturn {
|
|
const {
|
|
notifications: recentNotifications,
|
|
unreadCount,
|
|
isLoading,
|
|
refresh,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
} = useInAppNotifications({
|
|
initialFilters: { limit: 5, includeRead: false },
|
|
pollInterval,
|
|
});
|
|
|
|
return {
|
|
unreadCount,
|
|
recentNotifications,
|
|
isLoading,
|
|
refresh,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
};
|
|
}
|