@greatapps/greatchat-ui 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +106 -5
- package/dist/index.js +1787 -1116
- package/dist/index.js.map +1 -1
- package/package.json +17 -13
- package/src/components/channel-card.tsx +1 -1
- package/src/components/channel-create-dialog.tsx +126 -0
- package/src/components/channel-edit-dialog.tsx +132 -0
- package/src/components/channels-page.tsx +242 -0
- package/src/components/chat-dashboard.tsx +433 -0
- package/src/components/chat-input.tsx +1 -2
- package/src/components/chat-view.tsx +1 -8
- package/src/components/contact-avatar.tsx +1 -2
- package/src/components/contact-form-dialog.tsx +139 -0
- package/src/components/contact-info-panel.tsx +1 -4
- package/src/components/contacts-page.tsx +41 -0
- package/src/components/contacts-table.tsx +216 -0
- package/src/components/data-table.tsx +185 -0
- package/src/components/inbox-item.tsx +6 -4
- package/src/components/inbox-page.tsx +167 -0
- package/src/components/inbox-sidebar.tsx +1 -5
- package/src/components/message-bubble.tsx +4 -6
- package/src/components/new-conversation-dialog.tsx +2 -6
- package/src/components/whatsapp-icon.tsx +21 -0
- package/src/components/whatsapp-qr-dialog.tsx +147 -0
- package/src/components/whatsapp-status-badge.tsx +1 -1
- package/src/index.ts +37 -2
- package/src/components/ui/alert-dialog.tsx +0 -167
- package/src/components/ui/avatar.tsx +0 -51
- package/src/components/ui/badge.tsx +0 -44
- package/src/components/ui/button.tsx +0 -62
- package/src/components/ui/command.tsx +0 -106
- package/src/components/ui/dialog.tsx +0 -133
- package/src/components/ui/dropdown-menu.tsx +0 -173
- package/src/components/ui/input.tsx +0 -19
- package/src/components/ui/scroll-area.tsx +0 -50
- package/src/components/ui/select.tsx +0 -156
- package/src/components/ui/separator.tsx +0 -26
- package/src/components/ui/skeleton.tsx +0 -16
- package/src/components/ui/tabs.tsx +0 -64
- package/src/components/ui/textarea.tsx +0 -18
- package/src/components/ui/tooltip.tsx +0 -58
- package/src/lib/utils.ts +0 -6
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import type { Inbox, Channel } from "../types";
|
|
6
|
+
import type { GchatHookConfig } from "../hooks/types";
|
|
7
|
+
import { useInboxStats, useInboxes } from "../hooks/use-inboxes";
|
|
8
|
+
import { useChannels, useChannelWhatsappStatus } from "../hooks/use-channels";
|
|
9
|
+
import { ContactAvatar } from "./contact-avatar";
|
|
10
|
+
import { WhatsappStatusBadge } from "./whatsapp-status-badge";
|
|
11
|
+
import { WhatsappIcon } from "./whatsapp-icon";
|
|
12
|
+
import {
|
|
13
|
+
Card,
|
|
14
|
+
CardContent,
|
|
15
|
+
CardHeader,
|
|
16
|
+
CardTitle,
|
|
17
|
+
Badge,
|
|
18
|
+
Progress,
|
|
19
|
+
Skeleton,
|
|
20
|
+
cn,
|
|
21
|
+
} from "@greatapps/greatauth-ui/ui";
|
|
22
|
+
import {
|
|
23
|
+
Inbox as InboxIcon,
|
|
24
|
+
MessageCircle,
|
|
25
|
+
Clock,
|
|
26
|
+
CircleCheck,
|
|
27
|
+
ArrowRight,
|
|
28
|
+
} from "lucide-react";
|
|
29
|
+
import { formatDistanceToNow } from "date-fns";
|
|
30
|
+
import { ptBR } from "date-fns/locale";
|
|
31
|
+
|
|
32
|
+
// --- Helpers ---
|
|
33
|
+
|
|
34
|
+
function getGreeting(userName?: string): string {
|
|
35
|
+
const hour = new Date().getHours();
|
|
36
|
+
const prefix = hour < 12 ? "Bom dia" : hour < 18 ? "Boa tarde" : "Boa noite";
|
|
37
|
+
return userName ? `${prefix}, ${userName}!` : `${prefix}!`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const statusColors: Record<string, string> = {
|
|
41
|
+
open: "bg-green-500/10 text-green-600 border-green-200",
|
|
42
|
+
pending: "bg-yellow-500/10 text-yellow-600 border-yellow-200",
|
|
43
|
+
resolved: "bg-zinc-500/10 text-zinc-500 border-zinc-200",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const statusLabels: Record<string, string> = {
|
|
47
|
+
open: "Aberta",
|
|
48
|
+
pending: "Pendente",
|
|
49
|
+
resolved: "Resolvida",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// --- Sub-components ---
|
|
53
|
+
|
|
54
|
+
function StatCard({
|
|
55
|
+
title,
|
|
56
|
+
value,
|
|
57
|
+
total,
|
|
58
|
+
icon: Icon,
|
|
59
|
+
loading,
|
|
60
|
+
accentColor,
|
|
61
|
+
onClick,
|
|
62
|
+
}: {
|
|
63
|
+
title: string;
|
|
64
|
+
value: number | undefined;
|
|
65
|
+
total: number | undefined;
|
|
66
|
+
icon: React.ElementType;
|
|
67
|
+
loading: boolean;
|
|
68
|
+
accentColor: string;
|
|
69
|
+
onClick: () => void;
|
|
70
|
+
}) {
|
|
71
|
+
const pct = total && value ? Math.round((value / total) * 100) : 0;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Card
|
|
75
|
+
className={cn("cursor-pointer transition-shadow hover:shadow-md border-l-4", accentColor)}
|
|
76
|
+
onClick={onClick}
|
|
77
|
+
>
|
|
78
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
79
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
80
|
+
{title}
|
|
81
|
+
</CardTitle>
|
|
82
|
+
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
83
|
+
</CardHeader>
|
|
84
|
+
<CardContent className="space-y-2">
|
|
85
|
+
{loading ? (
|
|
86
|
+
<Skeleton className="h-8 w-16" />
|
|
87
|
+
) : (
|
|
88
|
+
<>
|
|
89
|
+
<div className="flex items-baseline gap-2">
|
|
90
|
+
<p className="text-2xl font-bold">{value ?? 0}</p>
|
|
91
|
+
{total !== undefined && total > 0 && title !== "Total" && (
|
|
92
|
+
<span className="text-xs text-muted-foreground">
|
|
93
|
+
de {total}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
{title !== "Total" && (
|
|
98
|
+
<Progress value={pct} className="h-1" />
|
|
99
|
+
)}
|
|
100
|
+
</>
|
|
101
|
+
)}
|
|
102
|
+
</CardContent>
|
|
103
|
+
</Card>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ChannelHealthCard({
|
|
108
|
+
channel,
|
|
109
|
+
config,
|
|
110
|
+
onClick,
|
|
111
|
+
}: {
|
|
112
|
+
channel: Channel;
|
|
113
|
+
config: GchatHookConfig;
|
|
114
|
+
onClick: () => void;
|
|
115
|
+
}) {
|
|
116
|
+
const { data: status } = useChannelWhatsappStatus(config, channel.id);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
className="flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors hover:bg-accent"
|
|
121
|
+
onClick={onClick}
|
|
122
|
+
>
|
|
123
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-green-500/10">
|
|
124
|
+
<WhatsappIcon className="h-5 w-5 text-green-600" />
|
|
125
|
+
</div>
|
|
126
|
+
<div className="flex-1 min-w-0">
|
|
127
|
+
<p className="text-sm font-medium truncate">{channel.name}</p>
|
|
128
|
+
<p className="text-xs text-muted-foreground truncate">
|
|
129
|
+
{channel.identifier || "Sem número"}
|
|
130
|
+
</p>
|
|
131
|
+
</div>
|
|
132
|
+
<WhatsappStatusBadge status={status} hasSession={!!channel.external_id} />
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function RecentConversationItem({
|
|
138
|
+
inbox,
|
|
139
|
+
onClick,
|
|
140
|
+
}: {
|
|
141
|
+
inbox: Inbox;
|
|
142
|
+
onClick: () => void;
|
|
143
|
+
}) {
|
|
144
|
+
const timestamp = inbox.last_message_at || inbox.datetime_add;
|
|
145
|
+
const timeAgo = formatDistanceToNow(new Date(timestamp), {
|
|
146
|
+
addSuffix: true,
|
|
147
|
+
locale: ptBR,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const lastMessage =
|
|
151
|
+
inbox.last_message_content_type === "text"
|
|
152
|
+
? inbox.last_message_content
|
|
153
|
+
: inbox.last_message_content_type
|
|
154
|
+
? `[${inbox.last_message_content_type}]`
|
|
155
|
+
: null;
|
|
156
|
+
|
|
157
|
+
const directionPrefix =
|
|
158
|
+
inbox.last_message_direction === "outbound" ? "Você: " : "";
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div
|
|
162
|
+
className="flex items-center gap-3 rounded-lg p-3 cursor-pointer transition-colors hover:bg-accent"
|
|
163
|
+
onClick={onClick}
|
|
164
|
+
>
|
|
165
|
+
<ContactAvatar name={inbox.contact_name || "?"} className="h-9 w-9" />
|
|
166
|
+
<div className="flex-1 min-w-0">
|
|
167
|
+
<div className="flex items-center justify-between gap-2">
|
|
168
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
169
|
+
<span className="text-sm font-medium truncate">
|
|
170
|
+
{inbox.contact_name || "Desconhecido"}
|
|
171
|
+
</span>
|
|
172
|
+
{inbox.channel_name && (
|
|
173
|
+
<Badge variant="outline" className="shrink-0 text-[10px] px-1.5 py-0 text-muted-foreground">
|
|
174
|
+
{inbox.channel_name}
|
|
175
|
+
</Badge>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
<span className="text-xs text-muted-foreground shrink-0">
|
|
179
|
+
{timeAgo}
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="flex items-center justify-between gap-2 mt-0.5">
|
|
183
|
+
<p className="text-xs text-muted-foreground truncate">
|
|
184
|
+
{lastMessage ? `${directionPrefix}${lastMessage}` : "Sem mensagens"}
|
|
185
|
+
</p>
|
|
186
|
+
<Badge
|
|
187
|
+
variant="outline"
|
|
188
|
+
className={cn("shrink-0 text-[10px] px-1.5 py-0", statusColors[inbox.status])}
|
|
189
|
+
>
|
|
190
|
+
{statusLabels[inbox.status] || inbox.status}
|
|
191
|
+
</Badge>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- Main Component ---
|
|
199
|
+
|
|
200
|
+
export interface ChatDashboardProps {
|
|
201
|
+
config: GchatHookConfig;
|
|
202
|
+
userName?: string;
|
|
203
|
+
onNavigateToInbox?: (filters?: { status?: string; channelId?: number }) => void;
|
|
204
|
+
onNavigateToChannels?: () => void;
|
|
205
|
+
renderExtraSection?: () => ReactNode;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function ChatDashboard({
|
|
209
|
+
config,
|
|
210
|
+
userName,
|
|
211
|
+
onNavigateToInbox,
|
|
212
|
+
onNavigateToChannels,
|
|
213
|
+
renderExtraSection,
|
|
214
|
+
}: ChatDashboardProps) {
|
|
215
|
+
const [selectedChannelId, setSelectedChannelId] = useState<number | null>(null);
|
|
216
|
+
|
|
217
|
+
const { data: stats, isLoading: statsLoading } = useInboxStats(config);
|
|
218
|
+
const { data: inboxes, isLoading: inboxesLoading } = useInboxes(config);
|
|
219
|
+
const { data: channels, isLoading: channelsLoading } = useChannels(config);
|
|
220
|
+
|
|
221
|
+
const filteredInboxes = useMemo(() => {
|
|
222
|
+
if (!inboxes) return [];
|
|
223
|
+
if (!selectedChannelId) return inboxes;
|
|
224
|
+
return inboxes.filter((i) => i.id_channel === selectedChannelId);
|
|
225
|
+
}, [inboxes, selectedChannelId]);
|
|
226
|
+
|
|
227
|
+
const filteredStats = useMemo(() => {
|
|
228
|
+
if (!selectedChannelId) return stats;
|
|
229
|
+
const open = filteredInboxes.filter((i) => i.status === "open").length;
|
|
230
|
+
const pending = filteredInboxes.filter((i) => i.status === "pending").length;
|
|
231
|
+
const resolved = filteredInboxes.filter((i) => i.status === "resolved").length;
|
|
232
|
+
return { total: filteredInboxes.length, open, pending, resolved };
|
|
233
|
+
}, [stats, filteredInboxes, selectedChannelId]);
|
|
234
|
+
|
|
235
|
+
const recentInboxes = useMemo(() => {
|
|
236
|
+
return [...filteredInboxes]
|
|
237
|
+
.sort((a, b) => {
|
|
238
|
+
const da = a.last_message_at || a.datetime_add;
|
|
239
|
+
const db = b.last_message_at || b.datetime_add;
|
|
240
|
+
return new Date(db).getTime() - new Date(da).getTime();
|
|
241
|
+
})
|
|
242
|
+
.slice(0, 5);
|
|
243
|
+
}, [filteredInboxes]);
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<div className="flex flex-col gap-6 p-4 flex-1 min-h-0 overflow-y-auto">
|
|
247
|
+
{/* Greeting */}
|
|
248
|
+
<div>
|
|
249
|
+
<h1 className="text-xl font-semibold">
|
|
250
|
+
{getGreeting(userName)}
|
|
251
|
+
</h1>
|
|
252
|
+
<p className="text-sm text-muted-foreground">
|
|
253
|
+
Aqui está o resumo das suas conversas e canais.
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{/* Channel Filter */}
|
|
258
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
259
|
+
<button
|
|
260
|
+
className={cn(
|
|
261
|
+
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
262
|
+
!selectedChannelId
|
|
263
|
+
? "bg-primary text-primary-foreground border-primary"
|
|
264
|
+
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
265
|
+
)}
|
|
266
|
+
onClick={() => setSelectedChannelId(null)}
|
|
267
|
+
>
|
|
268
|
+
Todos os canais
|
|
269
|
+
</button>
|
|
270
|
+
{channelsLoading ? (
|
|
271
|
+
<>
|
|
272
|
+
<Skeleton className="h-7 w-24 rounded-full" />
|
|
273
|
+
<Skeleton className="h-7 w-28 rounded-full" />
|
|
274
|
+
</>
|
|
275
|
+
) : (
|
|
276
|
+
channels?.map((channel) => (
|
|
277
|
+
<button
|
|
278
|
+
key={channel.id}
|
|
279
|
+
className={cn(
|
|
280
|
+
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
281
|
+
selectedChannelId === channel.id
|
|
282
|
+
? "bg-primary text-primary-foreground border-primary"
|
|
283
|
+
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
284
|
+
)}
|
|
285
|
+
onClick={() => setSelectedChannelId(channel.id)}
|
|
286
|
+
>
|
|
287
|
+
<WhatsappIcon className="h-3 w-3" />
|
|
288
|
+
{channel.name}
|
|
289
|
+
</button>
|
|
290
|
+
))
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Stat Cards */}
|
|
295
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
296
|
+
<StatCard
|
|
297
|
+
title="Total"
|
|
298
|
+
value={filteredStats?.total}
|
|
299
|
+
total={filteredStats?.total}
|
|
300
|
+
icon={InboxIcon}
|
|
301
|
+
loading={statsLoading && !selectedChannelId}
|
|
302
|
+
accentColor="border-l-blue-500"
|
|
303
|
+
onClick={() => onNavigateToInbox?.()}
|
|
304
|
+
/>
|
|
305
|
+
<StatCard
|
|
306
|
+
title="Abertas"
|
|
307
|
+
value={filteredStats?.open}
|
|
308
|
+
total={filteredStats?.total}
|
|
309
|
+
icon={MessageCircle}
|
|
310
|
+
loading={statsLoading && !selectedChannelId}
|
|
311
|
+
accentColor="border-l-green-500"
|
|
312
|
+
onClick={() => onNavigateToInbox?.({ status: "open" })}
|
|
313
|
+
/>
|
|
314
|
+
<StatCard
|
|
315
|
+
title="Pendentes"
|
|
316
|
+
value={filteredStats?.pending}
|
|
317
|
+
total={filteredStats?.total}
|
|
318
|
+
icon={Clock}
|
|
319
|
+
loading={statsLoading && !selectedChannelId}
|
|
320
|
+
accentColor="border-l-yellow-500"
|
|
321
|
+
onClick={() => onNavigateToInbox?.({ status: "pending" })}
|
|
322
|
+
/>
|
|
323
|
+
<StatCard
|
|
324
|
+
title="Resolvidas"
|
|
325
|
+
value={filteredStats?.resolved}
|
|
326
|
+
total={filteredStats?.total}
|
|
327
|
+
icon={CircleCheck}
|
|
328
|
+
loading={statsLoading && !selectedChannelId}
|
|
329
|
+
accentColor="border-l-zinc-400"
|
|
330
|
+
onClick={() => onNavigateToInbox?.({ status: "resolved" })}
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
{/* Channel Health */}
|
|
335
|
+
<Card>
|
|
336
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
337
|
+
<CardTitle className="text-sm font-medium">
|
|
338
|
+
Saúde dos Canais
|
|
339
|
+
</CardTitle>
|
|
340
|
+
{onNavigateToChannels && (
|
|
341
|
+
<button
|
|
342
|
+
onClick={onNavigateToChannels}
|
|
343
|
+
className="text-xs text-primary hover:underline flex items-center gap-1"
|
|
344
|
+
>
|
|
345
|
+
Gerenciar
|
|
346
|
+
<ArrowRight className="h-3 w-3" />
|
|
347
|
+
</button>
|
|
348
|
+
)}
|
|
349
|
+
</CardHeader>
|
|
350
|
+
<CardContent>
|
|
351
|
+
{channelsLoading ? (
|
|
352
|
+
<div className="space-y-3">
|
|
353
|
+
{[1, 2].map((i) => (
|
|
354
|
+
<div key={i} className="flex items-center gap-3 p-3">
|
|
355
|
+
<Skeleton className="h-8 w-8 rounded" />
|
|
356
|
+
<div className="flex-1 space-y-1">
|
|
357
|
+
<Skeleton className="h-4 w-24" />
|
|
358
|
+
<Skeleton className="h-3 w-32" />
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
))}
|
|
362
|
+
</div>
|
|
363
|
+
) : !channels?.length ? (
|
|
364
|
+
<div className="flex items-center justify-center h-full text-sm text-muted-foreground py-8">
|
|
365
|
+
Nenhum canal configurado
|
|
366
|
+
</div>
|
|
367
|
+
) : (
|
|
368
|
+
<div className="space-y-2">
|
|
369
|
+
{channels.map((channel) => (
|
|
370
|
+
<ChannelHealthCard
|
|
371
|
+
key={channel.id}
|
|
372
|
+
channel={channel}
|
|
373
|
+
config={config}
|
|
374
|
+
onClick={() => onNavigateToInbox?.({ channelId: channel.id })}
|
|
375
|
+
/>
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
</CardContent>
|
|
380
|
+
</Card>
|
|
381
|
+
|
|
382
|
+
{/* Extra Section (e.g., MetaAgents in admin) */}
|
|
383
|
+
{renderExtraSection?.()}
|
|
384
|
+
|
|
385
|
+
{/* Recent Conversations */}
|
|
386
|
+
<Card>
|
|
387
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
388
|
+
<CardTitle className="text-sm font-medium">
|
|
389
|
+
Conversas Recentes
|
|
390
|
+
</CardTitle>
|
|
391
|
+
{onNavigateToInbox && (
|
|
392
|
+
<button
|
|
393
|
+
onClick={() => onNavigateToInbox()}
|
|
394
|
+
className="text-xs text-primary hover:underline flex items-center gap-1"
|
|
395
|
+
>
|
|
396
|
+
Ver todas
|
|
397
|
+
<ArrowRight className="h-3 w-3" />
|
|
398
|
+
</button>
|
|
399
|
+
)}
|
|
400
|
+
</CardHeader>
|
|
401
|
+
<CardContent className="p-0">
|
|
402
|
+
{inboxesLoading ? (
|
|
403
|
+
<div className="space-y-2 p-4">
|
|
404
|
+
{[1, 2, 3].map((i) => (
|
|
405
|
+
<div key={i} className="flex items-center gap-3 p-3">
|
|
406
|
+
<Skeleton className="h-9 w-9 rounded-full shrink-0" />
|
|
407
|
+
<div className="flex-1 space-y-1.5">
|
|
408
|
+
<Skeleton className="h-4 w-28" />
|
|
409
|
+
<Skeleton className="h-3 w-40" />
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
))}
|
|
413
|
+
</div>
|
|
414
|
+
) : recentInboxes.length === 0 ? (
|
|
415
|
+
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
|
416
|
+
Nenhuma conversa encontrada
|
|
417
|
+
</div>
|
|
418
|
+
) : (
|
|
419
|
+
<div className="divide-y">
|
|
420
|
+
{recentInboxes.map((inbox) => (
|
|
421
|
+
<RecentConversationItem
|
|
422
|
+
key={inbox.id}
|
|
423
|
+
inbox={inbox}
|
|
424
|
+
onClick={() => onNavigateToInbox?.()}
|
|
425
|
+
/>
|
|
426
|
+
))}
|
|
427
|
+
</div>
|
|
428
|
+
)}
|
|
429
|
+
</CardContent>
|
|
430
|
+
</Card>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useState, useRef } from "react";
|
|
2
2
|
import { Send } from "lucide-react";
|
|
3
|
-
import { Button } from "
|
|
4
|
-
import { Textarea } from "./ui/textarea";
|
|
3
|
+
import { Button, Textarea } from "@greatapps/greatauth-ui/ui";
|
|
5
4
|
|
|
6
5
|
export interface ChatInputProps {
|
|
7
6
|
onSend: (content: string) => void;
|
|
@@ -4,14 +4,7 @@ import { groupMessagesByDate } from "../utils/group-messages";
|
|
|
4
4
|
import { formatDateGroup } from "../utils/format-date";
|
|
5
5
|
import { MessageBubble } from "./message-bubble";
|
|
6
6
|
import { ChatInput } from "./chat-input";
|
|
7
|
-
import { Skeleton } from "
|
|
8
|
-
import {
|
|
9
|
-
Select,
|
|
10
|
-
SelectContent,
|
|
11
|
-
SelectItem,
|
|
12
|
-
SelectTrigger,
|
|
13
|
-
SelectValue,
|
|
14
|
-
} from "./ui/select";
|
|
7
|
+
import { Skeleton, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@greatapps/greatauth-ui/ui";
|
|
15
8
|
|
|
16
9
|
export interface ChatViewProps {
|
|
17
10
|
messages: InboxMessage[];
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import type { Contact } from "../types";
|
|
5
|
+
import type { GchatHookConfig } from "../hooks/types";
|
|
6
|
+
import { useCreateContact, useUpdateContact } from "../hooks/use-contacts";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
Button,
|
|
14
|
+
Input,
|
|
15
|
+
Label,
|
|
16
|
+
} from "@greatapps/greatauth-ui/ui";
|
|
17
|
+
import { Loader2 } from "lucide-react";
|
|
18
|
+
import { toast } from "sonner";
|
|
19
|
+
|
|
20
|
+
export interface ContactFormDialogProps {
|
|
21
|
+
open: boolean;
|
|
22
|
+
onOpenChange: (open: boolean) => void;
|
|
23
|
+
contact?: Contact;
|
|
24
|
+
config: GchatHookConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ContactFormDialog({
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
contact,
|
|
31
|
+
config,
|
|
32
|
+
}: ContactFormDialogProps) {
|
|
33
|
+
const isEditing = !!contact;
|
|
34
|
+
const createContact = useCreateContact(config);
|
|
35
|
+
const updateContact = useUpdateContact(config);
|
|
36
|
+
|
|
37
|
+
const [name, setName] = useState("");
|
|
38
|
+
const [phoneNumber, setPhoneNumber] = useState("");
|
|
39
|
+
const [identifier, setIdentifier] = useState("");
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (contact) {
|
|
43
|
+
setName(contact.name);
|
|
44
|
+
setPhoneNumber(contact.phone_number || "");
|
|
45
|
+
setIdentifier(contact.identifier || "");
|
|
46
|
+
} else {
|
|
47
|
+
setName("");
|
|
48
|
+
setPhoneNumber("");
|
|
49
|
+
setIdentifier("");
|
|
50
|
+
}
|
|
51
|
+
}, [contact, open]);
|
|
52
|
+
|
|
53
|
+
const isPending = createContact.isPending || updateContact.isPending;
|
|
54
|
+
|
|
55
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
if (!name.trim()) return;
|
|
58
|
+
|
|
59
|
+
const body = {
|
|
60
|
+
name: name.trim(),
|
|
61
|
+
phone_number: phoneNumber.trim() || undefined,
|
|
62
|
+
identifier: identifier.trim() || undefined,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
if (isEditing) {
|
|
67
|
+
await updateContact.mutateAsync({ id: contact.id, body });
|
|
68
|
+
toast.success("Contato atualizado");
|
|
69
|
+
} else {
|
|
70
|
+
await createContact.mutateAsync(body);
|
|
71
|
+
toast.success("Contato criado");
|
|
72
|
+
}
|
|
73
|
+
onOpenChange(false);
|
|
74
|
+
} catch {
|
|
75
|
+
toast.error(isEditing ? "Erro ao atualizar" : "Erro ao criar contato");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
81
|
+
<DialogContent>
|
|
82
|
+
<DialogHeader>
|
|
83
|
+
<DialogTitle>
|
|
84
|
+
{isEditing ? "Editar Contato" : "Novo Contato"}
|
|
85
|
+
</DialogTitle>
|
|
86
|
+
</DialogHeader>
|
|
87
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
88
|
+
<div className="space-y-2">
|
|
89
|
+
<Label htmlFor="contact-name">Nome *</Label>
|
|
90
|
+
<Input
|
|
91
|
+
id="contact-name"
|
|
92
|
+
value={name}
|
|
93
|
+
onChange={(e) => setName(e.target.value)}
|
|
94
|
+
placeholder="Nome do contato"
|
|
95
|
+
required
|
|
96
|
+
disabled={isPending}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="space-y-2">
|
|
100
|
+
<Label htmlFor="contact-phone">Telefone</Label>
|
|
101
|
+
<Input
|
|
102
|
+
id="contact-phone"
|
|
103
|
+
value={phoneNumber}
|
|
104
|
+
onChange={(e) => setPhoneNumber(e.target.value)}
|
|
105
|
+
placeholder="5511999999999"
|
|
106
|
+
disabled={isPending}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="space-y-2">
|
|
110
|
+
<Label htmlFor="contact-identifier">Identificador (WhatsApp ID)</Label>
|
|
111
|
+
<Input
|
|
112
|
+
id="contact-identifier"
|
|
113
|
+
value={identifier}
|
|
114
|
+
onChange={(e) => setIdentifier(e.target.value)}
|
|
115
|
+
placeholder="5511999999999@s.whatsapp.net"
|
|
116
|
+
disabled={isPending}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
<DialogFooter>
|
|
120
|
+
<Button
|
|
121
|
+
type="button"
|
|
122
|
+
variant="outline"
|
|
123
|
+
onClick={() => onOpenChange(false)}
|
|
124
|
+
disabled={isPending}
|
|
125
|
+
>
|
|
126
|
+
Cancelar
|
|
127
|
+
</Button>
|
|
128
|
+
<Button type="submit" disabled={isPending || !name.trim()}>
|
|
129
|
+
{isPending ? (
|
|
130
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
131
|
+
) : null}
|
|
132
|
+
{isEditing ? "Salvar" : "Criar"}
|
|
133
|
+
</Button>
|
|
134
|
+
</DialogFooter>
|
|
135
|
+
</form>
|
|
136
|
+
</DialogContent>
|
|
137
|
+
</Dialog>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { Contact } from "../types";
|
|
2
2
|
import { ContactAvatar } from "./contact-avatar";
|
|
3
|
-
import { Button } from "
|
|
4
|
-
import { Skeleton } from "./ui/skeleton";
|
|
5
|
-
import { Separator } from "./ui/separator";
|
|
3
|
+
import { Button, Skeleton, Separator, cn } from "@greatapps/greatauth-ui/ui";
|
|
6
4
|
import {
|
|
7
5
|
X,
|
|
8
6
|
Phone,
|
|
@@ -13,7 +11,6 @@ import {
|
|
|
13
11
|
} from "lucide-react";
|
|
14
12
|
import { format } from "date-fns";
|
|
15
13
|
import { ptBR } from "date-fns/locale";
|
|
16
|
-
import { cn } from "../lib/utils";
|
|
17
14
|
|
|
18
15
|
function InfoRow({
|
|
19
16
|
icon: Icon,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { GchatHookConfig } from "../hooks/types";
|
|
5
|
+
import { ContactsTable } from "./contacts-table";
|
|
6
|
+
import { ContactFormDialog } from "./contact-form-dialog";
|
|
7
|
+
import { Button } from "@greatapps/greatauth-ui/ui";
|
|
8
|
+
import { Plus } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
export interface ContactsPageProps {
|
|
11
|
+
config: GchatHookConfig;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ContactsPage({ config }: ContactsPageProps) {
|
|
15
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex flex-col gap-6 p-4 flex-1 min-h-0 overflow-y-auto">
|
|
19
|
+
<div className="flex items-center justify-between">
|
|
20
|
+
<div>
|
|
21
|
+
<h1 className="text-xl font-semibold">Contatos</h1>
|
|
22
|
+
<p className="text-sm text-muted-foreground">
|
|
23
|
+
Gerencie seus contatos de conversas
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
<Button onClick={() => setCreateOpen(true)}>
|
|
27
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
28
|
+
Novo Contato
|
|
29
|
+
</Button>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<ContactsTable config={config} />
|
|
33
|
+
|
|
34
|
+
<ContactFormDialog
|
|
35
|
+
open={createOpen}
|
|
36
|
+
onOpenChange={setCreateOpen}
|
|
37
|
+
config={config}
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|