Replace notifications with activity feed

This commit is contained in:
Cauê Faleiros
2026-05-29 14:34:40 -03:00
parent 0f322e9c85
commit e64654c820
14 changed files with 154 additions and 313 deletions

View File

@@ -28,16 +28,16 @@ We have transitioned from a mock-based prototype to a **secure, multi-tenant pro
- Super Admins can generate persistent `api_keys` for specific tenants.
- `GET /api/integration/users`, `/funnels`, and `/origins` allow the n8n AI to dynamically map the tenant's actual agents and workflow stages before processing a chat.
- `POST /api/integration/attendances` accepts the AI's final JSON payload (including the `full_summary` text) and injects it directly into the dashboard.
- **Real-Time Notification System:**
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback (custom `.mp3` loaded via Vite).
- Automated Triggers: Super Admins are notified of new organizations; Admins/Super Admins are notified of new user setups; Agents are notified of team assignment changes; Managers get "Venda Fechada" alerts when n8n posts a converted lead.
- **Activity Feed:**
- Replaced the noisy real-time notification tray with an on-demand activity feed (`/api/activity`) that records important operational events without polling, unread state, delete actions, or sound playback.
- Automated activity events: Super Admins see new organizations; Admins/Super Admins see new user setups; Agents see team assignment changes; Managers see "Venda Fechada" events when n8n posts a converted lead.
- **Enhanced UI/UX:**
- Premium "Onyx & Gold" True Black dark mode (Zinc scale).
- Fully collapsible interactive sidebar with memory (`localStorage`).
- All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
## 📌 Roadmap / To-Do
- [ ] **Advanced AI Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance payload from n8n receives a critically low quality score (`score < 50`), or breaches a specific Response Time SLA (e.g., `first_response_time_min > 60`).
- [ ] **Critical Activity Alerts:** Add backend rules for high-signal events such as critically low attendance scores (`score < 50`) or response-time SLA breaches (`first_response_time_min > 60`).
- [ ] **Data Export/Reporting:** Allow Admins to export attendance and KPI data to CSV/Excel.
- [ ] **Billing/Subscription Management:** Integrate a payment gateway (e.g., Stripe/Asaas) to manage tenant trial periods and active statuses dynamically.

View File

@@ -15,7 +15,9 @@ const { createAuthRouter } = require('./routes/authRoutes');
const { createUsersRouter } = require('./routes/usersRoutes');
const { createTeamsRouter } = require('./routes/teamsRoutes');
const { createTenantsRouter } = require('./routes/tenantsRoutes');
const { createActivityRouter } = require('./routes/activityRoutes');
const { canReadAttendance } = require('./policies/accessPolicy');
const { recordActivity } = require('./services/activityService');
const app = express();
@@ -78,66 +80,7 @@ apiRouter.use(createTeamsRouter({ pool }));
apiRouter.use(createTenantsRouter({ pool, transporter, getBaseUrl }));
// --- Notifications Routes ---
apiRouter.get('/notifications', async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50',
[req.user.id]
);
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/notifications/read-all', async (req, res) => {
try {
await pool.query(
'UPDATE notifications SET is_read = true WHERE user_id = ?',
[req.user.id]
);
res.json({ message: 'All notifications marked as read' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/notifications/:id', async (req, res) => {
try {
await pool.query(
'UPDATE notifications SET is_read = true WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
res.json({ message: 'Notification marked as read' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/notifications/clear-all', async (req, res) => {
try {
await pool.query(
'DELETE FROM notifications WHERE user_id = ?',
[req.user.id]
);
res.json({ message: 'All notifications deleted' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/notifications/:id', async (req, res) => {
try {
await pool.query(
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
res.json({ message: 'Notification deleted' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.use(createActivityRouter({ pool }));
// --- Origin Routes (Groups & Items) ---
apiRouter.get('/origins', async (req, res) => {
@@ -719,10 +662,13 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
const agentName = agentInfo[0]?.name || 'Um agente';
for (const m of managers) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), m.id, 'success', 'Venda Fechada!', `${agentName} converteu um lead em ${funnel_stage}.`, `/attendances/${attId}`]
);
await recordActivity(pool, {
userId: m.id,
type: 'success',
title: 'Venda Fechada!',
message: `${agentName} converteu um lead em ${funnel_stage}.`,
link: `/attendances/${attId}`,
});
}
}
@@ -811,13 +757,6 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
console.log('Schema update note (populate slugs):', err.message);
}
// Add sound_enabled column if it doesn't exist
try {
await connection.query('ALTER TABLE users ADD COLUMN sound_enabled BOOLEAN DEFAULT true');
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (sound_enabled):', err.message);
}
// Update origin to VARCHAR for custom origins
try {
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin VARCHAR(255) NOT NULL");

View File

@@ -0,0 +1,30 @@
const express = require('express');
const createActivityRouter = ({ pool }) => {
const router = express.Router();
const listActivity = async (req, res) => {
try {
const [rows] = await pool.query(
`SELECT id, type, title, message, link, created_at
FROM notifications
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 50`,
[req.user.id]
);
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
};
router.get('/activity', listActivity);
router.get('/notifications', listActivity);
return router;
};
module.exports = {
createActivityRouter,
};

View File

@@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { hashSecret, maskSecret } = require('../utils/security');
const { requireRole } = require('../middleware/auth');
const { recordActivity } = require('../services/activityService');
const createAuthRouter = ({ pool, transporter, getBaseUrl, jwtSecret }) => {
const router = express.Router();
@@ -229,19 +230,25 @@ const createAuthRouter = ({ pool, transporter, getBaseUrl, jwtSecret }) => {
[user.tenant_id, user.id]
);
for (const n of notifiable) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`]
);
await recordActivity(pool, {
userId: n.id,
type: 'info',
title: 'Novo Membro Ativo',
message: `${name} concluiu o cadastro e já pode acessar o sistema.`,
link: `/users/${user.id}`,
});
}
if (user.role === 'admin') {
const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'");
for (const sa of superAdmins) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`]
);
await recordActivity(pool, {
userId: sa.id,
type: 'success',
title: 'Admin Ativo',
message: `O admin ${name} da organização configurou sua conta.`,
link: '/super-admin',
});
}
}
}

View File

@@ -1,6 +1,7 @@
const express = require('express');
const crypto = require('crypto');
const { requireRole } = require('../middleware/auth');
const { recordActivity } = require('../services/activityService');
const createTenantsRouter = ({ pool, transporter, getBaseUrl }) => {
const router = express.Router();
@@ -37,10 +38,13 @@ const createTenantsRouter = ({ pool, transporter, getBaseUrl }) => {
const [superAdmins] = await connection.query("SELECT id FROM users WHERE role = 'super_admin'");
for (const sa of superAdmins) {
await connection.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), sa.id, 'success', 'Nova Organização', `A organização ${name} foi criada.`, '/super-admin']
);
await recordActivity(connection, {
userId: sa.id,
type: 'success',
title: 'Nova Organização',
message: `A organização ${name} foi criada.`,
link: '/super-admin',
});
}
await transporter.sendMail({

View File

@@ -8,8 +8,9 @@ const {
canChangeUserEmail,
canManageUserRoleOrTeam,
} = require('../policies/accessPolicy');
const { recordActivity } = require('../services/activityService');
const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at';
const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, created_at';
const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => {
const router = express.Router();
@@ -118,7 +119,7 @@ const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => {
});
router.put('/users/:id', async (req, res) => {
const { name, bio, role, team_id, status, email, sound_enabled } = req.body;
const { name, bio, role, team_id, status, email } = req.body;
try {
const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
@@ -129,7 +130,6 @@ const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => {
const finalTeamId = canManageUserRoleOrTeam(req.user) && team_id !== undefined ? team_id : existing[0].team_id;
const finalStatus = canManageUserStatus(req.user) && status !== undefined ? status : existing[0].status;
const finalEmail = canChangeUserEmail(req.user, existing[0]) && email !== undefined ? email : existing[0].email;
const finalSoundEnabled = req.user.id === req.params.id && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
if (finalEmail !== existing[0].email) {
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
@@ -137,17 +137,20 @@ const createUsersRouter = ({ pool, upload, transporter, getBaseUrl }) => {
}
await pool.query(
'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ?, sound_enabled = ? WHERE id = ?',
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, finalSoundEnabled, req.params.id]
'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ? WHERE id = ?',
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, req.params.id]
);
if (finalTeamId && finalTeamId !== existing[0].team_id && existing[0].status === 'active') {
const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]);
if (team.length > 0) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), req.params.id, 'info', 'Novo Time', `Você foi adicionado ao time ${team[0].name}.`, '/']
);
await recordActivity(pool, {
userId: req.params.id,
type: 'info',
title: 'Novo Time',
message: `Você foi adicionado ao time ${team[0].name}.`,
link: '/',
});
}
}

View File

@@ -0,0 +1,14 @@
const crypto = require('crypto');
const recordActivity = async (db, { userId, type = 'info', title, message, link = null }) => {
if (!userId || !title || !message) return;
await db.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), userId, type, title, message, link]
);
};
module.exports = {
recordActivity,
};

Binary file not shown.

View File

@@ -7,11 +7,10 @@ import {
} from 'lucide-react';
import {
getAttendances, getUsers, getUserById, logout, searchGlobal,
getNotifications, markNotificationAsRead, markAllNotificationsAsRead,
deleteNotification, clearAllNotifications, returnToSuperAdmin
getActivityEvents, returnToSuperAdmin
} from '../services/dataService';
import { User } from '../types';
import notificationSound from '../assets/audio/notification.mp3';
import type { ActivityEvent } from '../services/activityService';
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
<NavLink
@@ -52,53 +51,19 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
const [isSearching, setIsSearching] = useState(false);
const [showSearchResults, setShowSearchResults] = useState(false);
// Notifications State
const [notifications, setNotifications] = useState<any[]>([]);
const [showNotifications, setShowNotifications] = useState(false);
const unreadCount = notifications.filter(n => !n.is_read).length;
const previousUnreadCountRef = React.useRef(0);
const isInitialLoadRef = React.useRef(true);
// Pre-initialize audio to ensure it's loaded and ready
const audioRef = React.useRef<HTMLAudioElement | null>(null);
// Activity Feed State
const [activityEvents, setActivityEvents] = useState<ActivityEvent[]>([]);
const [showActivity, setShowActivity] = useState(false);
const playNotificationSound = () => {
if (currentUser?.sound_enabled !== false && audioRef.current) {
// Reset time to 0 to allow rapid replays
audioRef.current.currentTime = 0;
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise.catch(e => console.log('Audio play blocked by browser policy:', e));
}
}
const loadActivityEvents = async () => {
const data = await getActivityEvents();
setActivityEvents(data);
};
const loadNotifications = async () => {
const data = await getNotifications();
const newUnreadCount = data.filter((n: any) => !n.is_read).length;
// Only play sound if it's NOT the first load AND the count actually increased
if (!isInitialLoadRef.current && newUnreadCount > previousUnreadCountRef.current) {
playNotificationSound();
}
setNotifications(data);
previousUnreadCountRef.current = newUnreadCount;
isInitialLoadRef.current = false;
};
const handleBellClick = async () => {
const willOpen = !showNotifications;
setShowNotifications(willOpen);
if (willOpen && unreadCount > 0) {
// Optimistic update
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
previousUnreadCountRef.current = 0;
await markAllNotificationsAsRead();
loadNotifications();
}
const handleActivityClick = async () => {
const willOpen = !showActivity;
setShowActivity(willOpen);
if (willOpen) await loadActivityEvents();
};
useEffect(() => {
@@ -144,9 +109,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
}
};
fetchCurrentUser();
loadNotifications();
const interval = setInterval(loadNotifications, 10000);
return () => clearInterval(interval);
loadActivityEvents();
}, [navigate]);
const handleLogout = () => {
@@ -499,94 +462,52 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{/* Notifications */}
<div className="relative">
<button
onClick={handleBellClick}
onClick={handleActivityClick}
className="p-2 text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-full relative transition-colors"
> <Bell size={20} />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-2 w-2.5 h-2.5 bg-brand-yellow rounded-full border-2 border-white dark:border-dark-header"></span>
)}
title="Atividades"
>
<Bell size={20} />
</button>
{showNotifications && (
{showActivity && (
<div className="absolute top-full mt-2 right-0 w-80 bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="p-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center bg-zinc-50/50 dark:bg-dark-bg/50">
<h3 className="font-bold text-zinc-900 dark:text-dark-text">Notificações</h3>
<div className="flex gap-3">
{unreadCount > 0 && (
<button
onClick={async (e) => {
e.stopPropagation();
await markAllNotificationsAsRead();
loadNotifications();
}}
className="text-xs text-brand-yellow hover:underline"
>
Marcar lidas
</button>
)}
{notifications.length > 0 && (
<button
onClick={async (e) => {
e.stopPropagation();
await clearAllNotifications();
loadNotifications();
}}
className="text-xs text-zinc-400 hover:text-red-500 hover:underline transition-colors"
>
Limpar tudo
</button>
)}
</div>
<h3 className="font-bold text-zinc-900 dark:text-dark-text">Atividades</h3>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length > 0 ? (
notifications.map(n => (
{activityEvents.length > 0 ? (
activityEvents.map(event => (
<div
key={n.id}
className={`w-full relative group p-4 text-left hover:bg-zinc-50 dark:hover:bg-dark-border transition-colors border-b border-zinc-50 dark:border-dark-border/50 last:border-0 ${!n.is_read ? 'bg-brand-yellow/5 dark:bg-brand-yellow/5' : ''}`}
key={event.id}
className="w-full relative group p-4 text-left hover:bg-zinc-50 dark:hover:bg-dark-border transition-colors border-b border-zinc-50 dark:border-dark-border/50 last:border-0"
>
<div
className="cursor-pointer pr-6"
onClick={async () => {
if (!n.is_read) await markNotificationAsRead(n.id);
if (n.link) navigate(n.link);
setShowNotifications(false);
loadNotifications();
className={event.link ? 'cursor-pointer' : ''}
onClick={() => {
if (event.link) navigate(event.link);
setShowActivity(false);
}}
>
<div className="flex justify-between items-start mb-1">
<span className={`text-xs font-bold uppercase tracking-wider ${
n.type === 'success' ? 'text-green-500' :
n.type === 'warning' ? 'text-orange-500' :
n.type === 'error' ? 'text-red-500' : 'text-blue-500'
event.type === 'success' ? 'text-green-500' :
event.type === 'warning' ? 'text-orange-500' :
event.type === 'error' ? 'text-red-500' : 'text-blue-500'
}`}>
{n.type === 'success' ? 'SUCESSO' : n.type === 'warning' ? 'AVISO' : n.type === 'error' ? 'ERRO' : 'INFO'}
{event.type === 'success' ? 'SUCESSO' : event.type === 'warning' ? 'AVISO' : event.type === 'error' ? 'ERRO' : 'INFO'}
</span>
<span className="text-[10px] text-zinc-400 dark:text-dark-muted">
{new Date(n.created_at).toLocaleDateString('pt-BR')}
{new Date(event.created_at).toLocaleDateString('pt-BR')}
</span>
</div>
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>
<p className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-2">{n.message}</p>
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{event.title}</div>
<p className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-2">{event.message}</p>
</div>
{/* Delete Button */}
<button
onClick={async (e) => {
e.stopPropagation();
await deleteNotification(n.id);
loadNotifications();
}}
className="absolute top-4 right-4 p-1 text-zinc-300 hover:text-red-500 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/30"
title="Remover notificação"
>
<X size={14} />
</button>
</div>
))
) : (
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
Nenhuma notificação por enquanto.
Nenhuma atividade por enquanto.
</div>
)}
</div>
@@ -594,8 +515,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
)}
</div>
{/* Close notifications when clicking outside */}
{showNotifications && <div className="fixed inset-0 z-40" onClick={() => setShowNotifications(false)} />}
{showActivity && <div className="fixed inset-0 z-40" onClick={() => setShowActivity(false)} />}
</div>
</header>
@@ -613,12 +533,6 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
/>
)}
{/* Hidden Audio Player for Notifications */}
<audio
ref={audioRef}
src={notificationSound}
preload="auto"
/>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell } from 'lucide-react';
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2 } from 'lucide-react';
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService';
import { User, Tenant } from '../types';
@@ -273,32 +273,6 @@ export const UserProfile: React.FC = () => {
<p className="text-xs text-zinc-400 dark:text-zinc-500 text-right">{bio.length}/500 caracteres</p>
</div>
<div className="space-y-2 pt-2">
<div className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-900/50 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div>
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
<Bell size={16} className="text-brand-yellow" /> Notificações Sonoras
</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
Reproduzir um som quando você receber uma nova notificação.
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={user.sound_enabled ?? true}
onChange={async (e) => {
const newStatus = e.target.checked;
setUser({...user, sound_enabled: newStatus});
await updateUser(user.id, { sound_enabled: newStatus });
}}
/>
<div className="w-11 h-6 bg-zinc-200 peer-focus:outline-none rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-zinc-600 peer-checked:bg-brand-yellow"></div>
</label>
</div>
</div>
<div className="pt-4 flex items-center justify-end border-t border-zinc-100 dark:border-zinc-800 mt-6 transition-colors">
<button
type="button"

View File

@@ -0,0 +1,23 @@
import { API_URL, apiFetch, getHeaders } from './apiClient';
export interface ActivityEvent {
id: string;
type: 'success' | 'info' | 'warning' | 'error';
title: string;
message: string;
link?: string | null;
created_at: string;
}
export const getActivityEvents = async (): Promise<ActivityEvent[]> => {
try {
const response = await apiFetch(`${API_URL}/activity`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Failed to fetch activity');
return await response.json();
} catch (error) {
console.error("API Error (getActivityEvents):", error);
return [];
}
};

View File

@@ -1,9 +1,9 @@
export * from './apiClient';
export * from './activityService';
export * from './attendancesService';
export * from './authService';
export * from './funnelsService';
export * from './integrationsService';
export * from './notificationsService';
export * from './originsService';
export * from './teamsService';
export * from './tenantsService';

View File

@@ -1,66 +0,0 @@
import { API_URL, apiFetch, getHeaders } from './apiClient';
export const getNotifications = async (): Promise<any[]> => {
try {
const response = await apiFetch(`${API_URL}/notifications`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Failed to fetch notifications');
return await response.json();
} catch (error) {
console.error("API Error (getNotifications):", error);
return [];
}
};
export const markNotificationAsRead = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'PUT',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (markNotificationAsRead):", error);
return false;
}
};
export const markAllNotificationsAsRead = async (): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/notifications/read-all`, {
method: 'PUT',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (markAllNotificationsAsRead):", error);
return false;
}
};
export const deleteNotification = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (deleteNotification):", error);
return false;
}
};
export const clearAllNotifications = async (): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (clearAllNotifications):", error);
return false;
}
};

View File

@@ -49,7 +49,6 @@ export interface User {
team_id: string;
bio?: string;
status: 'active' | 'inactive';
sound_enabled?: boolean;
}
export interface Attendance {