Skip to content

Commit 6ce2230

Browse files
author
Clément VALENTIN
committed
feat(contributions): ajouter la gestion des messages non lus et le comptage des contributions non lues
1 parent 51f4d1e commit 6ce2230

File tree

4 files changed

+111
-12
lines changed

4 files changed

+111
-12
lines changed

apps/api/src/routers/energy_offers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,11 @@ async def list_my_contributions(current_user: User = Depends(get_current_user),
261261
"created_at": msg.created_at.isoformat(),
262262
})
263263

264+
# Check for unread messages (for contributor: last message is from admin)
265+
has_unread = False
266+
if messages and messages[-1].is_from_admin:
267+
has_unread = True
268+
264269
# Get existing provider name if exists
265270
existing_provider_name = None
266271
if c.existing_provider_id:
@@ -271,6 +276,7 @@ async def list_my_contributions(current_user: User = Depends(get_current_user),
271276

272277
data.append({
273278
"id": c.id,
279+
"has_unread_messages": has_unread,
274280
"contribution_type": c.contribution_type,
275281
"status": c.status,
276282
# Provider info
@@ -299,6 +305,34 @@ async def list_my_contributions(current_user: User = Depends(get_current_user),
299305
return APIResponse(success=True, data=data)
300306

301307

308+
@router.get("/contributions/unread-count", response_model=APIResponse)
309+
async def get_unread_contributions_count(
310+
current_user: User = Depends(get_current_user),
311+
db: AsyncSession = Depends(get_db),
312+
) -> APIResponse:
313+
"""Get the count of contributions with unread admin messages for the current user"""
314+
result = await db.execute(
315+
select(OfferContribution).where(OfferContribution.contributor_user_id == current_user.id)
316+
)
317+
contributions = result.scalars().all()
318+
319+
unread_count = 0
320+
for c in contributions:
321+
# Get last message for this contribution
322+
messages_result = await db.execute(
323+
select(ContributionMessage)
324+
.where(ContributionMessage.contribution_id == c.id)
325+
.order_by(ContributionMessage.created_at.desc())
326+
.limit(1)
327+
)
328+
last_message = messages_result.scalar_one_or_none()
329+
# Unread for contributor = last message is from admin
330+
if last_message and last_message.is_from_admin:
331+
unread_count += 1
332+
333+
return APIResponse(success=True, data={"unread_count": unread_count})
334+
335+
302336
@router.post("/contributions/{contribution_id}/reply", response_model=APIResponse)
303337
async def reply_to_contribution(
304338
contribution_id: str,

apps/web/src/api/energy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,10 @@ export const energyApi = {
214214
return apiClient.get<Contribution[]>('energy/contributions')
215215
},
216216

217+
getUnreadContributionsCount: async () => {
218+
return apiClient.get<{ unread_count: number }>('energy/contributions/unread-count')
219+
},
220+
217221
replyToContribution: async (contributionId: string, message: string) => {
218222
return apiClient.post(`energy/contributions/${contributionId}/reply`, { message })
219223
},

apps/web/src/components/Layout.tsx

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Link, useLocation } from 'react-router-dom'
2-
import { Home, LogOut, Moon, Sun, Heart, Shield, BookOpen, Calculator, Users, Menu, X, Calendar, ChevronLeft, ChevronRight, HelpCircle, UserCircle, Zap, TrendingUp, Trash2, Scale, ChevronDown, Euro } from 'lucide-react'
2+
import { Home, LogOut, Moon, Sun, Heart, Shield, BookOpen, Calculator, Users, Menu, X, Calendar, ChevronLeft, ChevronRight, HelpCircle, UserCircle, Zap, TrendingUp, Trash2, Scale, ChevronDown, Euro, MessageCircle } from 'lucide-react'
33
import { useAuth } from '@/hooks/useAuth'
44
import { usePermissions } from '@/hooks/usePermissions'
55
import { useThemeStore } from '@/stores/themeStore'
@@ -12,8 +12,9 @@ import PageHeader from './PageHeader'
1212
import { PageTransition } from './PageTransition'
1313
import { SEO } from './SEO'
1414
import { useSEO } from '@/hooks/useSEO'
15-
import { useQueryClient } from '@tanstack/react-query'
15+
import { useQuery, useQueryClient } from '@tanstack/react-query'
1616
import { adminApi } from '@/api/admin'
17+
import { energyApi } from '@/api/energy'
1718

1819
export default function Layout({ children }: { children: React.ReactNode }) {
1920
const { user, logout } = useAuth()
@@ -32,6 +33,33 @@ export default function Layout({ children }: { children: React.ReactNode }) {
3233
const [adminMenuOpen, setAdminMenuOpen] = useState(false)
3334
const queryClient = useQueryClient()
3435

36+
// Fetch unread contributions count for regular users
37+
const { data: unreadContributionsData } = useQuery({
38+
queryKey: ['unread-contributions-count'],
39+
queryFn: async () => {
40+
const response = await energyApi.getUnreadContributionsCount()
41+
return response.data as { unread_count: number }
42+
},
43+
enabled: !!user,
44+
refetchInterval: 30000, // Refresh every 30 seconds
45+
staleTime: 0,
46+
})
47+
48+
// Fetch pending contributions count for admins
49+
const { data: adminStatsData } = useQuery({
50+
queryKey: ['admin-contribution-stats'],
51+
queryFn: async () => {
52+
const response = await energyApi.getContributionStats()
53+
return response.data as { pending_count: number, approved_this_month: number, rejected_count: number, approved_count: number }
54+
},
55+
enabled: !!user && canAccessAdmin(),
56+
refetchInterval: 30000,
57+
staleTime: 0,
58+
})
59+
60+
const unreadContributionsCount = unreadContributionsData?.unread_count || 0
61+
const pendingContributionsCount = adminStatsData?.pending_count || 0
62+
3563
// Persist sidebar state to localStorage
3664
useEffect(() => {
3765
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed))
@@ -53,7 +81,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
5381
{ to: '/production', icon: Sun, label: 'Production' },
5482
{ to: '/balance', icon: Scale, label: 'Bilan' },
5583
{ to: '/simulator', icon: Calculator, label: 'Simulateur' },
56-
{ to: '/contribute', icon: Users, label: 'Contribuer' },
84+
{ to: '/contribute', icon: Users, label: 'Contribuer', badgeCount: unreadContributionsCount },
5785
{ to: '/tempo', icon: Calendar, label: 'Tempo' },
5886
{ to: '/ecowatt', icon: Zap, label: 'EcoWatt' },
5987
]
@@ -70,7 +98,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
7098
{ to: '/admin/users', label: 'Utilisateurs' },
7199
{ to: '/admin/tempo', label: 'Tempo' },
72100
{ to: '/admin/ecowatt', label: 'EcoWatt' },
73-
{ to: '/admin/contributions', label: 'Contributions' },
101+
{ to: '/admin/contributions', label: 'Contributions', badgeCount: pendingContributionsCount },
74102
{ to: '/admin/offers', label: 'Offres' },
75103
{ to: '/admin/roles', label: 'Rôles' },
76104
{ to: '/admin/logs', label: 'Logs' },
@@ -263,11 +291,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
263291
{/* Other menu items */}
264292
{menuItems.filter(item => item.to !== '/dashboard').map((item) => {
265293
const isActive = location.pathname === item.to
294+
const hasBadge = item.badgeCount !== undefined && item.badgeCount > 0
266295
return (
267296
<Link
268297
key={item.to}
269298
to={item.to}
270-
className={`flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors ${
299+
className={`flex items-center gap-3 px-3 py-2.5 rounded-md transition-colors relative ${
271300
isActive
272301
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
273302
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
@@ -276,9 +305,23 @@ export default function Layout({ children }: { children: React.ReactNode }) {
276305
data-tour={item.to === '/simulator' ? 'nav-simulator' :
277306
item.to === '/contribute' ? 'nav-contribute' : undefined}
278307
>
279-
<item.icon size={20} className="flex-shrink-0" />
308+
<div className="relative">
309+
<item.icon size={20} className="flex-shrink-0" />
310+
{hasBadge && sidebarCollapsed && (
311+
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[10px] font-bold text-white">
312+
{item.badgeCount > 9 ? '9+' : item.badgeCount}
313+
</span>
314+
)}
315+
</div>
280316
{!sidebarCollapsed && (
281-
<span className="font-medium">{item.label}</span>
317+
<>
318+
<span className="font-medium">{item.label}</span>
319+
{hasBadge && (
320+
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-orange-500 px-1.5 text-xs font-bold text-white">
321+
{item.badgeCount > 99 ? '99+' : item.badgeCount}
322+
</span>
323+
)}
324+
</>
282325
)}
283326
</Link>
284327
)
@@ -324,13 +367,18 @@ export default function Layout({ children }: { children: React.ReactNode }) {
324367
<Link
325368
key={subItem.to}
326369
to={subItem.to}
327-
className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${
370+
className={`flex items-center justify-between px-3 py-2 rounded-md transition-colors ${
328371
location.pathname === subItem.to
329372
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
330373
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
331374
}`}
332375
>
333376
<span className="font-medium text-sm">{subItem.label}</span>
377+
{subItem.badgeCount && subItem.badgeCount > 0 && (
378+
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-orange-500 px-1.5 text-xs font-bold text-white">
379+
{subItem.badgeCount > 99 ? '99+' : subItem.badgeCount}
380+
</span>
381+
)}
334382
</Link>
335383
))}
336384
</div>
@@ -538,6 +586,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
538586
{/* Other menu items */}
539587
{menuItems.filter(item => item.to !== '/dashboard').map((item) => {
540588
const isActive = location.pathname === item.to
589+
const hasBadge = item.badgeCount !== undefined && item.badgeCount > 0
541590
return (
542591
<Link
543592
key={item.to}
@@ -549,8 +598,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
549598
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
550599
}`}
551600
>
552-
<item.icon size={20} />
601+
{hasBadge ? (
602+
<MessageCircle size={20} className="text-orange-500" />
603+
) : (
604+
<item.icon size={20} />
605+
)}
553606
<span className="font-medium">{item.label}</span>
607+
{hasBadge && (
608+
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-orange-500 px-1.5 text-xs font-bold text-white">
609+
{item.badgeCount > 99 ? '99+' : item.badgeCount}
610+
</span>
611+
)}
554612
</Link>
555613
)
556614
})}
@@ -593,13 +651,18 @@ export default function Layout({ children }: { children: React.ReactNode }) {
593651
key={subItem.to}
594652
to={subItem.to}
595653
onClick={() => setMobileMenuOpen(false)}
596-
className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${
654+
className={`flex items-center justify-between px-3 py-2 rounded-md transition-colors ${
597655
location.pathname === subItem.to
598656
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
599657
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
600658
}`}
601659
>
602660
<span className="font-medium text-sm">{subItem.label}</span>
661+
{subItem.badgeCount && subItem.badgeCount > 0 && (
662+
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-orange-500 px-1.5 text-xs font-bold text-white">
663+
{subItem.badgeCount > 99 ? '99+' : subItem.badgeCount}
664+
</span>
665+
)}
603666
</Link>
604667
))}
605668
</div>

apps/web/src/pages/AdminContributions.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,6 @@ export default function AdminContributions() {
228228
return await energyApi.requestContributionInfo(id, message)
229229
},
230230
onSuccess: (_data, variables) => {
231-
setNotification({ type: 'success', message: 'Message envoyé au contributeur.' })
232-
setTimeout(() => setNotification(null), 5000)
233231
// Reload messages for this contribution
234232
loadMessages(variables.id)
235233
// Clear the input

0 commit comments

Comments
 (0)