Skip to content

Commit 261aeb4

Browse files
author
Clément VALENTIN
committed
feat: Enhance admin contributions page with detailed features and statistics
- Updated the contributions management section with new functionalities including filtering, messaging system, and auto-refresh. - Improved the user contribution form to support new offer types and required documentation. - Introduced a new ChatWhatsApp component for better communication between admins and contributors. - Added API endpoints for managing contributions and messaging.
1 parent 4631798 commit 261aeb4

File tree

8 files changed

+2032
-403
lines changed

8 files changed

+2032
-403
lines changed

apps/api/src/routers/energy_offers.py

Lines changed: 539 additions & 15 deletions
Large diffs are not rendered by default.

apps/web/src/api/energy.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,61 @@ export interface ContributionData {
9494
screenshot_url?: string // OPTIONAL: Screenshot de la fiche des prix
9595
}
9696

97+
export interface ContributionMessage {
98+
id: string
99+
message_type: 'info_request' | 'contributor_response'
100+
content: string
101+
is_from_admin: boolean
102+
sender_email: string
103+
created_at: string
104+
}
105+
97106
export interface Contribution {
98107
id: string
99-
contribution_type: string
108+
contribution_type: 'NEW_PROVIDER' | 'NEW_OFFER' | 'UPDATE_OFFER'
100109
status: 'pending' | 'approved' | 'rejected'
110+
// Provider info
111+
provider_name?: string
112+
provider_website?: string
113+
existing_provider_id?: string
114+
existing_provider_name?: string
115+
// Offer info
101116
offer_name: string
102117
offer_type: string
118+
description?: string
119+
power_kva?: number
120+
// Pricing
121+
pricing_data?: {
122+
subscription_price?: number
123+
base_price?: number
124+
hc_price?: number
125+
hp_price?: number
126+
base_price_weekend?: number
127+
hc_price_weekend?: number
128+
hp_price_weekend?: number
129+
tempo_blue_hc?: number
130+
tempo_blue_hp?: number
131+
tempo_white_hc?: number
132+
tempo_white_hp?: number
133+
tempo_red_hc?: number
134+
tempo_red_hp?: number
135+
ejp_normal?: number
136+
ejp_peak?: number
137+
hc_price_winter?: number
138+
hp_price_winter?: number
139+
hc_price_summer?: number
140+
hp_price_summer?: number
141+
peak_day_price?: number
142+
}
143+
hc_schedules?: Record<string, string>
144+
// Documentation
145+
price_sheet_url?: string
146+
screenshot_url?: string
147+
// Timestamps
103148
created_at: string
104149
reviewed_at?: string
105150
review_comment?: string
151+
messages?: ContributionMessage[]
106152
}
107153

108154
export interface OfferChange {
@@ -160,11 +206,23 @@ export const energyApi = {
160206
return apiClient.post('energy/contribute', data)
161207
},
162208

209+
updateContribution: async (contributionId: string, data: ContributionData) => {
210+
return apiClient.put(`energy/contributions/${contributionId}`, data)
211+
},
212+
163213
getMyContributions: async () => {
164214
return apiClient.get<Contribution[]>('energy/contributions')
165215
},
166216

217+
replyToContribution: async (contributionId: string, message: string) => {
218+
return apiClient.post(`energy/contributions/${contributionId}/reply`, { message })
219+
},
220+
167221
// Admin endpoints
222+
getContributionStats: async () => {
223+
return apiClient.get('energy/contributions/stats')
224+
},
225+
168226
getPendingContributions: async () => {
169227
return apiClient.get('energy/contributions/pending')
170228
},
@@ -185,6 +243,14 @@ export const energyApi = {
185243
return apiClient.get(`energy/contributions/${contributionId}/messages`)
186244
},
187245

246+
bulkApproveContributions: async (ids: string[]) => {
247+
return apiClient.post('energy/contributions/bulk-approve', { contribution_ids: ids })
248+
},
249+
250+
bulkRejectContributions: async (ids: string[], reason: string) => {
251+
return apiClient.post('energy/contributions/bulk-reject', { contribution_ids: ids, reason })
252+
},
253+
188254
// Admin - Manage offers
189255
updateOffer: async (offerId: string, data: Partial<EnergyOffer>) => {
190256
return apiClient.put(`energy/offers/${offerId}`, data)
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { useRef, useEffect } from 'react'
2+
import { MessageCircle, Shield } from 'lucide-react'
3+
4+
export interface ChatMessage {
5+
id: string
6+
content: string
7+
is_from_admin: boolean
8+
sender_email?: string
9+
created_at: string
10+
}
11+
12+
interface ChatWhatsAppProps {
13+
messages: ChatMessage[]
14+
/** Whether the current user is the admin viewing this chat */
15+
isAdminView?: boolean
16+
/** Input value for the message */
17+
inputValue: string
18+
/** Callback when input value changes */
19+
onInputChange: (value: string) => void
20+
/** Callback when sending a message */
21+
onSend: () => void
22+
/** Whether sending is in progress */
23+
isSending?: boolean
24+
/** Whether to show the input area */
25+
showInput?: boolean
26+
/** Placeholder text for the input */
27+
placeholder?: string
28+
/** Minimum height of the chat area */
29+
minHeight?: string
30+
/** Maximum height of the chat area */
31+
maxHeight?: string
32+
}
33+
34+
export default function ChatWhatsApp({
35+
messages,
36+
isAdminView = false,
37+
inputValue,
38+
onInputChange,
39+
onSend,
40+
isSending = false,
41+
showInput = true,
42+
placeholder = 'Écrire un message...',
43+
minHeight = '200px',
44+
maxHeight = '400px',
45+
}: ChatWhatsAppProps) {
46+
const messagesEndRef = useRef<HTMLDivElement>(null)
47+
48+
// Auto-scroll to bottom when messages change
49+
useEffect(() => {
50+
if (messagesEndRef.current) {
51+
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
52+
}
53+
}, [messages])
54+
55+
const handleKeyDown = (e: React.KeyboardEvent) => {
56+
if (e.key === 'Enter' && !e.shiftKey && inputValue.trim()) {
57+
e.preventDefault()
58+
onSend()
59+
}
60+
}
61+
62+
// Determine if a message is "mine" based on the view context
63+
const isMyMessage = (msg: ChatMessage) => {
64+
if (isAdminView) {
65+
return msg.is_from_admin
66+
}
67+
return !msg.is_from_admin
68+
}
69+
70+
return (
71+
<div className="rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700">
72+
{/* Messages area */}
73+
<div
74+
className="p-4 overflow-y-auto bg-gray-50 dark:bg-gray-900"
75+
style={{ minHeight, maxHeight }}
76+
>
77+
78+
{messages.length > 0 ? (
79+
<div className="space-y-2 relative">
80+
{messages.map((msg) => {
81+
const isMine = isMyMessage(msg)
82+
83+
return (
84+
<div
85+
key={msg.id}
86+
className={`flex ${isMine ? 'justify-end' : 'justify-start'}`}
87+
>
88+
<div
89+
className={`relative max-w-[85%] px-3 py-2 rounded-lg shadow-sm ${
90+
isMine
91+
? 'bg-primary-100 dark:bg-emerald-800 rounded-br-none'
92+
: 'bg-white dark:bg-gray-800 rounded-bl-none'
93+
}`}
94+
>
95+
{/* Bubble tail */}
96+
<div
97+
className={`absolute bottom-0 w-3 h-3 ${
98+
isMine
99+
? '-right-1.5 bg-primary-100 dark:bg-emerald-800'
100+
: '-left-1.5 bg-white dark:bg-gray-800'
101+
}`}
102+
style={{
103+
clipPath: isMine
104+
? 'polygon(0 0, 100% 100%, 0 100%)'
105+
: 'polygon(100% 0, 100% 100%, 0 100%)'
106+
}}
107+
/>
108+
109+
{/* Sender label */}
110+
{!isMine && (
111+
<div className="flex items-center gap-1 text-[10px] font-semibold text-primary-600 dark:text-primary-400 mb-0.5">
112+
{isAdminView ? (
113+
// Admin viewing contributor's message
114+
<span>{msg.sender_email?.split('@')[0] || 'Contributeur'}</span>
115+
) : (
116+
// Contributor viewing admin's message
117+
<>
118+
<Shield size={10} />
119+
<span>Modérateur</span>
120+
</>
121+
)}
122+
</div>
123+
)}
124+
125+
{/* Message content */}
126+
<p className="text-sm whitespace-pre-wrap text-gray-800 dark:text-gray-100">
127+
{msg.content}
128+
</p>
129+
130+
{/* Timestamp and check marks */}
131+
<div className={`flex items-center justify-end gap-1 mt-1 ${
132+
isMine
133+
? 'text-primary-500 dark:text-emerald-400'
134+
: 'text-gray-500 dark:text-gray-400'
135+
}`}>
136+
<span className="text-[10px]">
137+
{new Date(msg.created_at).toLocaleTimeString('fr-FR', {
138+
hour: '2-digit',
139+
minute: '2-digit'
140+
})}
141+
</span>
142+
{isMine && (
143+
<svg className="w-4 h-4" viewBox="0 0 16 15" fill="currentColor">
144+
<path d="M15.01 3.316l-.478-.372a.365.365 0 0 0-.51.063L8.666 9.879a.32.32 0 0 1-.484.033l-.358-.325a.319.319 0 0 0-.484.032l-.378.483a.418.418 0 0 0 .036.541l1.32 1.266c.143.14.361.125.484-.033l6.272-8.048a.366.366 0 0 0-.064-.512zm-4.1 0l-.478-.372a.365.365 0 0 0-.51.063L4.566 9.879a.32.32 0 0 1-.484.033L1.891 7.769a.366.366 0 0 0-.515.006l-.423.433a.364.364 0 0 0 .006.514l3.258 3.185c.143.14.361.125.484-.033l6.272-8.048a.365.365 0 0 0-.063-.51z" />
145+
</svg>
146+
)}
147+
</div>
148+
</div>
149+
</div>
150+
)
151+
})}
152+
<div ref={messagesEndRef} />
153+
</div>
154+
) : (
155+
<div className="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
156+
<div className="text-center py-8">
157+
<MessageCircle className="mx-auto mb-2 opacity-50" size={32} />
158+
<p className="text-sm italic">Aucun message</p>
159+
</div>
160+
</div>
161+
)}
162+
</div>
163+
164+
{/* Input area */}
165+
{showInput && (
166+
<div className="flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
167+
<input
168+
type="text"
169+
value={inputValue}
170+
onChange={(e) => onInputChange(e.target.value)}
171+
onKeyDown={handleKeyDown}
172+
placeholder={placeholder}
173+
disabled={isSending}
174+
className="flex-1 px-4 py-2.5 bg-white dark:bg-gray-900 border-0 rounded-full text-sm focus:ring-2 focus:ring-primary-500 focus:outline-none placeholder-gray-400 dark:placeholder-gray-500 dark:text-white"
175+
/>
176+
<button
177+
onClick={onSend}
178+
disabled={!inputValue.trim() || isSending}
179+
className="p-2.5 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-300 dark:disabled:bg-gray-600 text-white rounded-full transition-colors disabled:cursor-not-allowed"
180+
>
181+
{isSending ? (
182+
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
183+
) : (
184+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
185+
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
186+
</svg>
187+
)}
188+
</button>
189+
</div>
190+
)}
191+
</div>
192+
)
193+
}

apps/web/src/index.css

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@
2323

2424
/* ===== SCROLLBAR DESIGN ===== */
2525

26-
/* Firefox */
26+
/* Firefox - Light Mode */
2727
* {
2828
scrollbar-width: thin;
29-
scrollbar-color: #0ea5e9 #1e293b;
29+
scrollbar-color: #0ea5e9 #e2e8f0;
3030
}
3131

32+
/* Firefox - Dark Mode */
3233
.dark * {
3334
scrollbar-color: #38bdf8 #0f172a;
3435
}
@@ -39,21 +40,22 @@
3940
height: 10px;
4041
}
4142

42-
/* Track - fond sombre de la scrollbar */
43+
/* Track - Light Mode */
4344
*::-webkit-scrollbar-track {
44-
background: #1e293b;
45+
background: #e2e8f0;
4546
border-radius: 5px;
4647
}
4748

49+
/* Track - Dark Mode */
4850
.dark *::-webkit-scrollbar-track {
4951
background: #0f172a;
5052
}
5153

52-
/* Thumb - partie mobile */
54+
/* Thumb - Light Mode */
5355
*::-webkit-scrollbar-thumb {
5456
background: linear-gradient(180deg, #0ea5e9 0%, #0284c7 100%);
5557
border-radius: 5px;
56-
border: 2px solid #1e293b;
58+
border: 2px solid #e2e8f0;
5759
transition: background 0.2s ease;
5860
}
5961

@@ -65,7 +67,7 @@
6567
background: linear-gradient(180deg, #0284c7 0%, #0369a1 100%);
6668
}
6769

68-
/* Dark Mode Scrollbar */
70+
/* Thumb - Dark Mode */
6971
.dark *::-webkit-scrollbar-thumb {
7072
background: linear-gradient(180deg, #38bdf8 0%, #0ea5e9 100%);
7173
border: 2px solid #0f172a;
@@ -79,11 +81,12 @@
7981
background: linear-gradient(180deg, #0ea5e9 0%, #0284c7 100%);
8082
}
8183

82-
/* Corner where scrollbars meet */
84+
/* Corner - Light Mode */
8385
*::-webkit-scrollbar-corner {
84-
background: #1e293b;
86+
background: #e2e8f0;
8587
}
8688

89+
/* Corner - Dark Mode */
8790
.dark *::-webkit-scrollbar-corner {
8891
background: #0f172a;
8992
}
@@ -103,6 +106,7 @@
103106
.btn {
104107
@apply px-4 py-2 rounded-xl font-medium transition-all duration-200;
105108
@apply focus:outline-none focus:ring-2 focus:ring-offset-2;
109+
@apply disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none;
106110
}
107111

108112
.btn-primary {

0 commit comments

Comments
 (0)