@hed-hog/contact 0.0.294 → 0.0.295
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/person/dto/account.dto.d.ts +28 -0
- package/dist/person/dto/account.dto.d.ts.map +1 -0
- package/dist/person/dto/account.dto.js +123 -0
- package/dist/person/dto/account.dto.js.map +1 -0
- package/dist/person/dto/activity.dto.d.ts +15 -0
- package/dist/person/dto/activity.dto.d.ts.map +1 -0
- package/dist/person/dto/activity.dto.js +65 -0
- package/dist/person/dto/activity.dto.js.map +1 -0
- package/dist/person/dto/dashboard-query.dto.d.ts +9 -0
- package/dist/person/dto/dashboard-query.dto.d.ts.map +1 -0
- package/dist/person/dto/dashboard-query.dto.js +40 -0
- package/dist/person/dto/dashboard-query.dto.js.map +1 -0
- package/dist/person/dto/followup-query.dto.d.ts +10 -0
- package/dist/person/dto/followup-query.dto.d.ts.map +1 -0
- package/dist/person/dto/followup-query.dto.js +45 -0
- package/dist/person/dto/followup-query.dto.js.map +1 -0
- package/dist/person/person.controller.d.ts +204 -0
- package/dist/person/person.controller.d.ts.map +1 -1
- package/dist/person/person.controller.js +138 -0
- package/dist/person/person.controller.js.map +1 -1
- package/dist/person/person.service.d.ts +234 -0
- package/dist/person/person.service.d.ts.map +1 -1
- package/dist/person/person.service.js +1367 -0
- package/dist/person/person.service.js.map +1 -1
- package/hedhog/data/menu.yaml +163 -163
- package/hedhog/data/route.yaml +41 -0
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +210 -114
- package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +3 -0
- package/hedhog/frontend/app/accounts/page.tsx.ejs +323 -245
- package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
- package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
- package/hedhog/frontend/app/activities/page.tsx.ejs +165 -517
- package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +504 -356
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +242 -153
- package/hedhog/frontend/messages/en.json +91 -6
- package/hedhog/frontend/messages/pt.json +91 -6
- package/hedhog/table/crm_activity.yaml +68 -0
- package/hedhog/table/person_company.yaml +22 -0
- package/package.json +4 -4
- package/src/person/dto/account.dto.ts +100 -0
- package/src/person/dto/activity.dto.ts +54 -0
- package/src/person/dto/dashboard-query.dto.ts +25 -0
- package/src/person/dto/followup-query.dto.ts +25 -0
- package/src/person/person.controller.ts +116 -0
- package/src/person/person.service.ts +2139 -77
|
@@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge';
|
|
|
12
12
|
import { Button } from '@/components/ui/button';
|
|
13
13
|
import { Card, CardContent } from '@/components/ui/card';
|
|
14
14
|
import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
|
|
15
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
15
16
|
import {
|
|
16
17
|
Table,
|
|
17
18
|
TableBody,
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
24
25
|
import { formatDateTime } from '@/lib/format-date';
|
|
25
26
|
import { cn } from '@/lib/utils';
|
|
26
|
-
import { useApp } from '@hed-hog/next-app-provider';
|
|
27
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
27
28
|
import {
|
|
28
29
|
Activity,
|
|
29
30
|
CalendarCheck,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
Eye,
|
|
35
36
|
LayoutGrid,
|
|
36
37
|
List,
|
|
38
|
+
Loader2,
|
|
37
39
|
Mail,
|
|
38
40
|
MessageCircle,
|
|
39
41
|
NotebookPen,
|
|
@@ -42,165 +44,21 @@ import {
|
|
|
42
44
|
Users,
|
|
43
45
|
} from 'lucide-react';
|
|
44
46
|
import { useTranslations } from 'next-intl';
|
|
45
|
-
import { useEffect,
|
|
47
|
+
import { useEffect, useState } from 'react';
|
|
46
48
|
import { toast } from 'sonner';
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
import { ActivityDetailSheet } from './_components/activity-detail-sheet';
|
|
50
|
+
import type {
|
|
51
|
+
ActivityListItem,
|
|
52
|
+
ActivityPriority,
|
|
53
|
+
ActivityStats,
|
|
54
|
+
ActivityStatus,
|
|
55
|
+
ActivityType,
|
|
56
|
+
ActivityViewMode,
|
|
57
|
+
PaginatedResult,
|
|
58
|
+
} from './_components/activity-types';
|
|
53
59
|
|
|
54
60
|
const ACTIVITIES_VIEW_STORAGE_KEY = 'contact-activities-view-mode';
|
|
55
61
|
|
|
56
|
-
type CrmActivity = {
|
|
57
|
-
id: number;
|
|
58
|
-
personId: number;
|
|
59
|
-
ownerId: number | null;
|
|
60
|
-
type: ActivityType;
|
|
61
|
-
subject: string;
|
|
62
|
-
notes: string;
|
|
63
|
-
dueAt: string;
|
|
64
|
-
completedAt?: string | null;
|
|
65
|
-
createdAt: string;
|
|
66
|
-
priority: ActivityPriority;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
function buildMockDate(offsetDays: number, hour: number, minute: number) {
|
|
70
|
-
const value = new Date();
|
|
71
|
-
value.setSeconds(0, 0);
|
|
72
|
-
value.setDate(value.getDate() + offsetDays);
|
|
73
|
-
value.setHours(hour, minute, 0, 0);
|
|
74
|
-
return value.toISOString();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function createMockActivities(): CrmActivity[] {
|
|
78
|
-
const leads = crmMockLeads.slice(0, 10);
|
|
79
|
-
|
|
80
|
-
return [
|
|
81
|
-
{
|
|
82
|
-
id: 9001,
|
|
83
|
-
personId: leads[0]?.id ?? 101,
|
|
84
|
-
ownerId: 1,
|
|
85
|
-
type: 'call',
|
|
86
|
-
subject: 'Follow-up da proposta comercial',
|
|
87
|
-
notes: 'Validar decisão até o final da tarde.',
|
|
88
|
-
dueAt: buildMockDate(-1, 10, 30),
|
|
89
|
-
createdAt: buildMockDate(-3, 8, 45),
|
|
90
|
-
priority: 'high',
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
id: 9002,
|
|
94
|
-
personId: leads[1]?.id ?? 102,
|
|
95
|
-
ownerId: 2,
|
|
96
|
-
type: 'email',
|
|
97
|
-
subject: 'Enviar comparativo de planos',
|
|
98
|
-
notes: 'Anexar proposta enterprise com SLA.',
|
|
99
|
-
dueAt: buildMockDate(0, 14, 0),
|
|
100
|
-
createdAt: buildMockDate(-2, 9, 15),
|
|
101
|
-
priority: 'medium',
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
id: 9003,
|
|
105
|
-
personId: leads[2]?.id ?? 103,
|
|
106
|
-
ownerId: 1,
|
|
107
|
-
type: 'whatsapp',
|
|
108
|
-
subject: 'Confirmar data da demo',
|
|
109
|
-
notes: 'Cliente pediu nova janela para sexta.',
|
|
110
|
-
dueAt: buildMockDate(1, 11, 0),
|
|
111
|
-
createdAt: buildMockDate(-1, 16, 10),
|
|
112
|
-
priority: 'low',
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
id: 9004,
|
|
116
|
-
personId: leads[3]?.id ?? 104,
|
|
117
|
-
ownerId: 3,
|
|
118
|
-
type: 'meeting',
|
|
119
|
-
subject: 'Reunião de alinhamento técnico',
|
|
120
|
-
notes: 'Levar material de integração.',
|
|
121
|
-
dueAt: buildMockDate(2, 15, 30),
|
|
122
|
-
createdAt: buildMockDate(-4, 10, 0),
|
|
123
|
-
priority: 'high',
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
id: 9005,
|
|
127
|
-
personId: leads[4]?.id ?? 105,
|
|
128
|
-
ownerId: 4,
|
|
129
|
-
type: 'task',
|
|
130
|
-
subject: 'Atualizar CRM com objeções de preço',
|
|
131
|
-
notes: 'Tag de sensibilidade ao valor.',
|
|
132
|
-
dueAt: buildMockDate(-2, 17, 0),
|
|
133
|
-
createdAt: buildMockDate(-5, 8, 30),
|
|
134
|
-
priority: 'medium',
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
id: 9006,
|
|
138
|
-
personId: leads[5]?.id ?? 106,
|
|
139
|
-
ownerId: 2,
|
|
140
|
-
type: 'note',
|
|
141
|
-
subject: 'Resumo do comitê de compra',
|
|
142
|
-
notes: 'Decisão prevista para próxima semana.',
|
|
143
|
-
dueAt: buildMockDate(3, 9, 45),
|
|
144
|
-
createdAt: buildMockDate(-1, 11, 50),
|
|
145
|
-
priority: 'low',
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
id: 9007,
|
|
149
|
-
personId: leads[6]?.id ?? 107,
|
|
150
|
-
ownerId: 3,
|
|
151
|
-
type: 'call',
|
|
152
|
-
subject: 'Reengajar oportunidade inativa',
|
|
153
|
-
notes: 'Sondar timing para reabertura.',
|
|
154
|
-
dueAt: buildMockDate(0, 16, 10),
|
|
155
|
-
createdAt: buildMockDate(-2, 13, 25),
|
|
156
|
-
completedAt: buildMockDate(0, 16, 40),
|
|
157
|
-
priority: 'low',
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
id: 9008,
|
|
161
|
-
personId: leads[7]?.id ?? 108,
|
|
162
|
-
ownerId: null,
|
|
163
|
-
type: 'email',
|
|
164
|
-
subject: 'Enviar material de onboarding',
|
|
165
|
-
notes: 'Sem owner definido, fila do SDR.',
|
|
166
|
-
dueAt: buildMockDate(1, 10, 0),
|
|
167
|
-
createdAt: buildMockDate(-1, 9, 0),
|
|
168
|
-
priority: 'high',
|
|
169
|
-
},
|
|
170
|
-
{
|
|
171
|
-
id: 9009,
|
|
172
|
-
personId: leads[8]?.id ?? 109,
|
|
173
|
-
ownerId: 4,
|
|
174
|
-
type: 'meeting',
|
|
175
|
-
subject: 'Sessão de revisão contratual',
|
|
176
|
-
notes: 'Ajustar cláusulas de prazo.',
|
|
177
|
-
dueAt: buildMockDate(-1, 12, 0),
|
|
178
|
-
createdAt: buildMockDate(-3, 15, 10),
|
|
179
|
-
completedAt: buildMockDate(-1, 12, 50),
|
|
180
|
-
priority: 'high',
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
id: 9010,
|
|
184
|
-
personId: leads[9]?.id ?? 110,
|
|
185
|
-
ownerId: 1,
|
|
186
|
-
type: 'task',
|
|
187
|
-
subject: 'Registrar decisão de comitê',
|
|
188
|
-
notes: 'Atualizar etapa no pipeline após retorno.',
|
|
189
|
-
dueAt: buildMockDate(4, 14, 15),
|
|
190
|
-
createdAt: buildMockDate(-1, 10, 20),
|
|
191
|
-
priority: 'medium',
|
|
192
|
-
},
|
|
193
|
-
];
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function resolveActivityStatus(activity: CrmActivity): ActivityStatus {
|
|
197
|
-
if (activity.completedAt) {
|
|
198
|
-
return 'completed';
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return new Date(activity.dueAt) < new Date() ? 'overdue' : 'pending';
|
|
202
|
-
}
|
|
203
|
-
|
|
204
62
|
function getTypeIcon(type: ActivityType) {
|
|
205
63
|
if (type === 'call') return Phone;
|
|
206
64
|
if (type === 'email') return Mail;
|
|
@@ -210,38 +68,23 @@ function getTypeIcon(type: ActivityType) {
|
|
|
210
68
|
return Activity;
|
|
211
69
|
}
|
|
212
70
|
|
|
213
|
-
function
|
|
214
|
-
if (status === 'completed')
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (status === 'overdue') {
|
|
219
|
-
return 'border-red-500/25 bg-red-500/10 text-red-700';
|
|
220
|
-
}
|
|
221
|
-
|
|
71
|
+
function getStatusBadgeClass(status: ActivityStatus) {
|
|
72
|
+
if (status === 'completed') return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700';
|
|
73
|
+
if (status === 'overdue') return 'border-red-500/25 bg-red-500/10 text-red-700';
|
|
222
74
|
return 'border-amber-500/25 bg-amber-500/10 text-amber-700';
|
|
223
75
|
}
|
|
224
76
|
|
|
225
|
-
function
|
|
226
|
-
if (priority === 'high')
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (priority === 'medium') {
|
|
231
|
-
return 'border-sky-500/25 bg-sky-500/10 text-sky-700';
|
|
232
|
-
}
|
|
233
|
-
|
|
77
|
+
function getPriorityBadgeClass(priority: ActivityPriority) {
|
|
78
|
+
if (priority === 'high') return 'border-red-500/25 bg-red-500/10 text-red-700';
|
|
79
|
+
if (priority === 'medium') return 'border-sky-500/25 bg-sky-500/10 text-sky-700';
|
|
234
80
|
return 'border-slate-500/25 bg-slate-500/10 text-slate-700';
|
|
235
81
|
}
|
|
236
82
|
|
|
237
83
|
export default function CrmActivitiesPage() {
|
|
238
84
|
const t = useTranslations('contact.CrmActivities');
|
|
239
85
|
const crmT = useTranslations('contact.CrmMenu');
|
|
240
|
-
const { currentLocaleCode, getSettingValue } = useApp();
|
|
86
|
+
const { request, currentLocaleCode, getSettingValue } = useApp();
|
|
241
87
|
|
|
242
|
-
const [activities, setActivities] = useState<CrmActivity[]>(() =>
|
|
243
|
-
createMockActivities()
|
|
244
|
-
);
|
|
245
88
|
const [searchInput, setSearchInput] = useState('');
|
|
246
89
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
247
90
|
const [statusFilter, setStatusFilter] = useState('all');
|
|
@@ -250,147 +93,90 @@ export default function CrmActivitiesPage() {
|
|
|
250
93
|
const [page, setPage] = useState(1);
|
|
251
94
|
const [pageSize, setPageSize] = useState(12);
|
|
252
95
|
const [viewMode, setViewMode] = useState<ActivityViewMode>('table');
|
|
96
|
+
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null);
|
|
97
|
+
const [detailOpen, setDetailOpen] = useState(false);
|
|
98
|
+
const [completingActivityId, setCompletingActivityId] = useState<number | null>(null);
|
|
99
|
+
const [detailRefreshKey, setDetailRefreshKey] = useState(0);
|
|
253
100
|
|
|
254
101
|
useEffect(() => {
|
|
255
|
-
const timeout = setTimeout(() =>
|
|
256
|
-
setDebouncedSearch(searchInput.trim().toLowerCase());
|
|
257
|
-
}, 250);
|
|
258
|
-
|
|
102
|
+
const timeout = setTimeout(() => setDebouncedSearch(searchInput.trim()), 300);
|
|
259
103
|
return () => clearTimeout(timeout);
|
|
260
104
|
}, [searchInput]);
|
|
261
105
|
|
|
262
106
|
useEffect(() => {
|
|
263
107
|
try {
|
|
264
108
|
const saved = window.localStorage.getItem(ACTIVITIES_VIEW_STORAGE_KEY);
|
|
265
|
-
if (saved === 'table' || saved === 'timeline')
|
|
266
|
-
setViewMode(saved);
|
|
267
|
-
}
|
|
109
|
+
if (saved === 'table' || saved === 'timeline') setViewMode(saved);
|
|
268
110
|
} catch {
|
|
269
111
|
// Ignore storage read failures.
|
|
270
112
|
}
|
|
271
113
|
}, []);
|
|
272
114
|
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
})
|
|
286
|
-
.sort(
|
|
287
|
-
(left, right) =>
|
|
288
|
-
new Date(left.dueAt).getTime() - new Date(right.dueAt).getTime()
|
|
289
|
-
);
|
|
290
|
-
}, [activities, t]);
|
|
291
|
-
|
|
292
|
-
const filteredActivities = useMemo(() => {
|
|
293
|
-
return enrichedActivities.filter((activity) => {
|
|
294
|
-
if (statusFilter !== 'all' && activity.status !== statusFilter) {
|
|
295
|
-
return false;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (typeFilter !== 'all' && activity.type !== typeFilter) {
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (priorityFilter !== 'all' && activity.priority !== priorityFilter) {
|
|
303
|
-
return false;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
if (!debouncedSearch) {
|
|
307
|
-
return true;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const target = [
|
|
311
|
-
activity.subject,
|
|
312
|
-
activity.notes,
|
|
313
|
-
activity.personName,
|
|
314
|
-
activity.ownerName,
|
|
315
|
-
]
|
|
316
|
-
.join(' ')
|
|
317
|
-
.toLowerCase();
|
|
318
|
-
|
|
319
|
-
return target.includes(debouncedSearch);
|
|
115
|
+
const { data: stats = { total: 0, pending: 0, overdue: 0, completed: 0 }, refetch: refetchStats } =
|
|
116
|
+
useQuery<ActivityStats>({
|
|
117
|
+
queryKey: ['contact-activities-stats', currentLocaleCode],
|
|
118
|
+
queryFn: async () => {
|
|
119
|
+
const response = await request<ActivityStats>({
|
|
120
|
+
url: '/person/activities/stats',
|
|
121
|
+
method: 'GET',
|
|
122
|
+
});
|
|
123
|
+
return response.data;
|
|
124
|
+
},
|
|
125
|
+
placeholderData: (previous) =>
|
|
126
|
+
previous ?? { total: 0, pending: 0, overdue: 0, completed: 0 },
|
|
320
127
|
});
|
|
321
|
-
}, [
|
|
322
|
-
enrichedActivities,
|
|
323
|
-
statusFilter,
|
|
324
|
-
typeFilter,
|
|
325
|
-
priorityFilter,
|
|
326
|
-
debouncedSearch,
|
|
327
|
-
]);
|
|
328
|
-
|
|
329
|
-
const totalPages = Math.max(
|
|
330
|
-
1,
|
|
331
|
-
Math.ceil(filteredActivities.length / pageSize)
|
|
332
|
-
);
|
|
333
|
-
|
|
334
|
-
useEffect(() => {
|
|
335
|
-
if (page > totalPages) {
|
|
336
|
-
setPage(totalPages);
|
|
337
|
-
}
|
|
338
|
-
}, [page, totalPages]);
|
|
339
|
-
|
|
340
|
-
const pageData = useMemo(() => {
|
|
341
|
-
const start = (page - 1) * pageSize;
|
|
342
|
-
return filteredActivities.slice(start, start + pageSize);
|
|
343
|
-
}, [filteredActivities, page, pageSize]);
|
|
344
|
-
|
|
345
|
-
const stats = useMemo(() => {
|
|
346
|
-
const total = enrichedActivities.length;
|
|
347
|
-
const pending = enrichedActivities.filter(
|
|
348
|
-
(item) => item.status === 'pending'
|
|
349
|
-
).length;
|
|
350
|
-
const overdue = enrichedActivities.filter(
|
|
351
|
-
(item) => item.status === 'overdue'
|
|
352
|
-
).length;
|
|
353
|
-
const completed = enrichedActivities.filter(
|
|
354
|
-
(item) => item.status === 'completed'
|
|
355
|
-
).length;
|
|
356
128
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
accentClassName: 'from-violet-500/20 via-fuchsia-500/10 to-transparent',
|
|
367
|
-
iconContainerClassName: 'bg-violet-500/10 text-violet-700',
|
|
129
|
+
const {
|
|
130
|
+
data: paginate = {
|
|
131
|
+
data: [],
|
|
132
|
+
total: 0,
|
|
133
|
+
page: 1,
|
|
134
|
+
pageSize: 12,
|
|
135
|
+
lastPage: 1,
|
|
136
|
+
prev: null,
|
|
137
|
+
next: null,
|
|
368
138
|
},
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
139
|
+
isLoading,
|
|
140
|
+
refetch: refetchActivities,
|
|
141
|
+
} = useQuery<PaginatedResult<ActivityListItem>>({
|
|
142
|
+
queryKey: [
|
|
143
|
+
'contact-activities',
|
|
144
|
+
page,
|
|
145
|
+
pageSize,
|
|
146
|
+
debouncedSearch,
|
|
147
|
+
statusFilter,
|
|
148
|
+
typeFilter,
|
|
149
|
+
priorityFilter,
|
|
150
|
+
currentLocaleCode,
|
|
151
|
+
],
|
|
152
|
+
queryFn: async () => {
|
|
153
|
+
const params = new URLSearchParams({
|
|
154
|
+
page: String(page),
|
|
155
|
+
pageSize: String(pageSize),
|
|
156
|
+
});
|
|
157
|
+
if (debouncedSearch) params.set('search', debouncedSearch);
|
|
158
|
+
if (statusFilter !== 'all') params.set('status', statusFilter);
|
|
159
|
+
if (typeFilter !== 'all') params.set('type', typeFilter);
|
|
160
|
+
if (priorityFilter !== 'all') params.set('priority', priorityFilter);
|
|
161
|
+
|
|
162
|
+
const response = await request<PaginatedResult<ActivityListItem>>({
|
|
163
|
+
url: `/person/activities?${params.toString()}`,
|
|
164
|
+
method: 'GET',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return response.data;
|
|
376
168
|
},
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
value: stats.completed,
|
|
389
|
-
icon: CalendarCheck,
|
|
390
|
-
accentClassName: 'from-emerald-500/20 via-green-500/10 to-transparent',
|
|
391
|
-
iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
|
|
392
|
-
},
|
|
393
|
-
];
|
|
169
|
+
placeholderData: (previous) =>
|
|
170
|
+
previous ?? {
|
|
171
|
+
data: [],
|
|
172
|
+
total: 0,
|
|
173
|
+
page: 1,
|
|
174
|
+
pageSize: 12,
|
|
175
|
+
lastPage: 1,
|
|
176
|
+
prev: null,
|
|
177
|
+
next: null,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
394
180
|
|
|
395
181
|
const controls: SearchBarControl[] = [
|
|
396
182
|
{
|
|
@@ -446,32 +232,16 @@ export default function CrmActivitiesPage() {
|
|
|
446
232
|
},
|
|
447
233
|
];
|
|
448
234
|
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
completedAt: new Date().toISOString(),
|
|
456
|
-
}
|
|
457
|
-
: item
|
|
458
|
-
)
|
|
459
|
-
);
|
|
460
|
-
|
|
461
|
-
toast.success(t('toasts.markedAsCompleted'));
|
|
462
|
-
};
|
|
463
|
-
|
|
464
|
-
const handleView = (subject: string) => {
|
|
465
|
-
toast.info(t('toasts.openDetails', { subject }));
|
|
466
|
-
};
|
|
235
|
+
const statsCards = [
|
|
236
|
+
{ key: 'total', title: t('stats.total'), value: stats.total, icon: Activity, accentClassName: 'from-violet-500/20 via-fuchsia-500/10 to-transparent', iconContainerClassName: 'bg-violet-500/10 text-violet-700' },
|
|
237
|
+
{ key: 'pending', title: t('stats.pending'), value: stats.pending, icon: Clock3, accentClassName: 'from-amber-500/20 via-yellow-500/10 to-transparent', iconContainerClassName: 'bg-amber-500/10 text-amber-700' },
|
|
238
|
+
{ key: 'overdue', title: t('stats.overdue'), value: stats.overdue, icon: CircleAlert, accentClassName: 'from-red-500/20 via-rose-500/10 to-transparent', iconContainerClassName: 'bg-red-500/10 text-red-700' },
|
|
239
|
+
{ key: 'completed', title: t('stats.completed'), value: stats.completed, icon: CalendarCheck, accentClassName: 'from-emerald-500/20 via-green-500/10 to-transparent', iconContainerClassName: 'bg-emerald-500/10 text-emerald-700' },
|
|
240
|
+
];
|
|
467
241
|
|
|
468
242
|
const handleViewModeChange = (value: string) => {
|
|
469
|
-
if (value !== 'table' && value !== 'timeline')
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
|
|
243
|
+
if (value !== 'table' && value !== 'timeline') return;
|
|
473
244
|
setViewMode(value);
|
|
474
|
-
|
|
475
245
|
try {
|
|
476
246
|
window.localStorage.setItem(ACTIVITIES_VIEW_STORAGE_KEY, value);
|
|
477
247
|
} catch {
|
|
@@ -479,6 +249,20 @@ export default function CrmActivitiesPage() {
|
|
|
479
249
|
}
|
|
480
250
|
};
|
|
481
251
|
|
|
252
|
+
const handleComplete = async (activityId: number) => {
|
|
253
|
+
setCompletingActivityId(activityId);
|
|
254
|
+
try {
|
|
255
|
+
await request({ url: `/person/activities/${activityId}/complete`, method: 'POST' });
|
|
256
|
+
setDetailRefreshKey((current) => current + 1);
|
|
257
|
+
await Promise.all([refetchActivities(), refetchStats()]);
|
|
258
|
+
toast.success(t('toasts.markedAsCompleted'));
|
|
259
|
+
} catch {
|
|
260
|
+
toast.error(t('toasts.completeError'));
|
|
261
|
+
} finally {
|
|
262
|
+
setCompletingActivityId(null);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
482
266
|
return (
|
|
483
267
|
<Page>
|
|
484
268
|
<PageHeader
|
|
@@ -506,37 +290,26 @@ export default function CrmActivitiesPage() {
|
|
|
506
290
|
/>
|
|
507
291
|
|
|
508
292
|
<div className="flex items-center justify-end gap-3">
|
|
509
|
-
<span className="text-xs font-medium text-muted-foreground">
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
<ToggleGroup
|
|
513
|
-
type="single"
|
|
514
|
-
value={viewMode}
|
|
515
|
-
onValueChange={handleViewModeChange}
|
|
516
|
-
variant="outline"
|
|
517
|
-
size="sm"
|
|
518
|
-
aria-label={t('viewMode')}
|
|
519
|
-
>
|
|
520
|
-
<ToggleGroupItem
|
|
521
|
-
value="table"
|
|
522
|
-
className="gap-1.5 px-2.5"
|
|
523
|
-
aria-label={t('viewModeTable')}
|
|
524
|
-
>
|
|
293
|
+
<span className="text-xs font-medium text-muted-foreground">{t('viewMode')}</span>
|
|
294
|
+
<ToggleGroup type="single" value={viewMode} onValueChange={handleViewModeChange} variant="outline" size="sm" aria-label={t('viewMode')}>
|
|
295
|
+
<ToggleGroupItem value="table" className="gap-1.5 px-2.5" aria-label={t('viewModeTable')}>
|
|
525
296
|
<List className="h-4 w-4" />
|
|
526
297
|
<span className="hidden sm:inline">{t('viewModeTable')}</span>
|
|
527
298
|
</ToggleGroupItem>
|
|
528
|
-
<ToggleGroupItem
|
|
529
|
-
value="timeline"
|
|
530
|
-
className="gap-1.5 px-2.5"
|
|
531
|
-
aria-label={t('viewModeTimeline')}
|
|
532
|
-
>
|
|
299
|
+
<ToggleGroupItem value="timeline" className="gap-1.5 px-2.5" aria-label={t('viewModeTimeline')}>
|
|
533
300
|
<LayoutGrid className="h-4 w-4" />
|
|
534
301
|
<span className="hidden sm:inline">{t('viewModeTimeline')}</span>
|
|
535
302
|
</ToggleGroupItem>
|
|
536
303
|
</ToggleGroup>
|
|
537
304
|
</div>
|
|
538
305
|
|
|
539
|
-
{
|
|
306
|
+
{isLoading ? (
|
|
307
|
+
<div className="space-y-3">
|
|
308
|
+
<Skeleton className="h-14 w-full" />
|
|
309
|
+
<Skeleton className="h-14 w-full" />
|
|
310
|
+
<Skeleton className="h-14 w-full" />
|
|
311
|
+
</div>
|
|
312
|
+
) : paginate.data.length === 0 ? (
|
|
540
313
|
<EmptyState
|
|
541
314
|
icon={<CalendarClock className="h-12 w-12" />}
|
|
542
315
|
title={t('empty.title')}
|
|
@@ -562,15 +335,14 @@ export default function CrmActivitiesPage() {
|
|
|
562
335
|
<TableHead>{t('table.dueAt')}</TableHead>
|
|
563
336
|
<TableHead>{t('table.status')}</TableHead>
|
|
564
337
|
<TableHead>{t('table.priority')}</TableHead>
|
|
565
|
-
<TableHead className="text-right">
|
|
566
|
-
{t('table.actions')}
|
|
567
|
-
</TableHead>
|
|
338
|
+
<TableHead className="text-right">{t('table.actions')}</TableHead>
|
|
568
339
|
</TableRow>
|
|
569
340
|
</TableHeader>
|
|
570
341
|
<TableBody>
|
|
571
|
-
{
|
|
342
|
+
{paginate.data.map((item) => {
|
|
572
343
|
const TypeIcon = getTypeIcon(item.type);
|
|
573
|
-
const
|
|
344
|
+
const isCompleted = item.status === 'completed';
|
|
345
|
+
const isCompleting = completingActivityId === item.id;
|
|
574
346
|
|
|
575
347
|
return (
|
|
576
348
|
<TableRow key={item.id}>
|
|
@@ -580,83 +352,25 @@ export default function CrmActivitiesPage() {
|
|
|
580
352
|
<TypeIcon className="h-4 w-4 text-muted-foreground" />
|
|
581
353
|
<p className="font-medium">{item.subject}</p>
|
|
582
354
|
</div>
|
|
583
|
-
<p className="line-clamp-2 text-xs text-muted-foreground">
|
|
584
|
-
{item.notes}
|
|
585
|
-
</p>
|
|
355
|
+
<p className="line-clamp-2 text-xs text-muted-foreground">{item.notes || t('detail.emptyNotes')}</p>
|
|
586
356
|
<div className="text-[11px] text-muted-foreground">
|
|
587
|
-
{t('table.createdAt')}:{
|
|
588
|
-
{formatDateTime(
|
|
589
|
-
item.createdAt,
|
|
590
|
-
getSettingValue,
|
|
591
|
-
currentLocaleCode
|
|
592
|
-
)}
|
|
357
|
+
{t('table.createdAt')}: {formatDateTime(item.created_at, getSettingValue, currentLocaleCode)}
|
|
593
358
|
</div>
|
|
594
359
|
</div>
|
|
595
360
|
</TableCell>
|
|
596
|
-
<TableCell>
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
</TableCell>
|
|
602
|
-
<TableCell>
|
|
603
|
-
<div className="inline-flex items-center gap-2">
|
|
604
|
-
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
|
605
|
-
<span>{item.ownerName}</span>
|
|
606
|
-
</div>
|
|
607
|
-
</TableCell>
|
|
608
|
-
<TableCell>
|
|
609
|
-
{formatDateTime(
|
|
610
|
-
item.dueAt,
|
|
611
|
-
getSettingValue,
|
|
612
|
-
currentLocaleCode
|
|
613
|
-
)}
|
|
614
|
-
</TableCell>
|
|
615
|
-
<TableCell>
|
|
616
|
-
<Badge
|
|
617
|
-
variant="outline"
|
|
618
|
-
className={cn(
|
|
619
|
-
'border',
|
|
620
|
-
statusBadgeClass(item.status)
|
|
621
|
-
)}
|
|
622
|
-
>
|
|
623
|
-
{t(`status.${item.status}`)}
|
|
624
|
-
</Badge>
|
|
625
|
-
</TableCell>
|
|
626
|
-
<TableCell>
|
|
627
|
-
<Badge
|
|
628
|
-
variant="outline"
|
|
629
|
-
className={cn(
|
|
630
|
-
'border',
|
|
631
|
-
priorityBadgeClass(item.priority)
|
|
632
|
-
)}
|
|
633
|
-
>
|
|
634
|
-
{t(`priority.${item.priority}`)}
|
|
635
|
-
</Badge>
|
|
636
|
-
</TableCell>
|
|
361
|
+
<TableCell><div className="inline-flex items-center gap-2"><Users className="h-3.5 w-3.5 text-muted-foreground" /><span>{item.person.name}</span></div></TableCell>
|
|
362
|
+
<TableCell><div className="inline-flex items-center gap-2"><User className="h-3.5 w-3.5 text-muted-foreground" /><span>{item.owner_user?.name || t('unassignedOwner')}</span></div></TableCell>
|
|
363
|
+
<TableCell>{formatDateTime(item.due_at, getSettingValue, currentLocaleCode)}</TableCell>
|
|
364
|
+
<TableCell><Badge variant="outline" className={cn('border', getStatusBadgeClass(item.status))}>{t(`status.${item.status}`)}</Badge></TableCell>
|
|
365
|
+
<TableCell><Badge variant="outline" className={cn('border', getPriorityBadgeClass(item.priority))}>{t(`priority.${item.priority}`)}</Badge></TableCell>
|
|
637
366
|
<TableCell className="text-right">
|
|
638
367
|
<div className="inline-flex items-center gap-2">
|
|
639
|
-
<Button
|
|
640
|
-
type="button"
|
|
641
|
-
variant="outline"
|
|
642
|
-
size="sm"
|
|
643
|
-
onClick={() => handleView(item.subject)}
|
|
644
|
-
>
|
|
368
|
+
<Button type="button" variant="outline" size="sm" onClick={() => { setSelectedActivityId(item.id); setDetailOpen(true); }}>
|
|
645
369
|
<Eye className="mr-2 h-3.5 w-3.5" />
|
|
646
370
|
{t('actions.view')}
|
|
647
371
|
</Button>
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
type="button"
|
|
651
|
-
variant="outline"
|
|
652
|
-
size="sm"
|
|
653
|
-
onClick={() => handleComplete(item.id)}
|
|
654
|
-
disabled={completed}
|
|
655
|
-
className={cn(
|
|
656
|
-
completed && 'cursor-not-allowed opacity-60'
|
|
657
|
-
)}
|
|
658
|
-
>
|
|
659
|
-
<CheckCircle2 className="mr-2 h-3.5 w-3.5" />
|
|
372
|
+
<Button type="button" variant="outline" size="sm" onClick={() => handleComplete(item.id)} disabled={isCompleted || isCompleting} className={cn((isCompleted || isCompleting) && 'cursor-not-allowed opacity-60')}>
|
|
373
|
+
{isCompleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-2 h-3.5 w-3.5" />}
|
|
660
374
|
{t('actions.complete')}
|
|
661
375
|
</Button>
|
|
662
376
|
</div>
|
|
@@ -669,119 +383,41 @@ export default function CrmActivitiesPage() {
|
|
|
669
383
|
</div>
|
|
670
384
|
) : (
|
|
671
385
|
<div className="space-y-4">
|
|
672
|
-
<div className="rounded-md border border-dashed border-border/70 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
|
673
|
-
{t('timeline.description')}
|
|
674
|
-
</div>
|
|
675
|
-
|
|
386
|
+
<div className="rounded-md border border-dashed border-border/70 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">{t('timeline.description')}</div>
|
|
676
387
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
677
|
-
{
|
|
388
|
+
{paginate.data.map((item) => {
|
|
678
389
|
const TypeIcon = getTypeIcon(item.type);
|
|
679
|
-
const
|
|
390
|
+
const isCompleted = item.status === 'completed';
|
|
391
|
+
const isCompleting = completingActivityId === item.id;
|
|
680
392
|
|
|
681
393
|
return (
|
|
682
|
-
<Card
|
|
683
|
-
key={item.id}
|
|
684
|
-
className="h-full overflow-hidden border-border/70 py-0"
|
|
685
|
-
>
|
|
394
|
+
<Card key={item.id} className="h-full overflow-hidden border-border/70 py-0">
|
|
686
395
|
<CardContent className="space-y-3 p-4">
|
|
687
396
|
<div className="flex items-start justify-between gap-3">
|
|
688
397
|
<div className="min-w-0 space-y-1">
|
|
689
398
|
<div className="flex items-center gap-2">
|
|
690
399
|
<TypeIcon className="h-4 w-4 text-muted-foreground" />
|
|
691
|
-
<p className="line-clamp-2 text-sm font-semibold">
|
|
692
|
-
{item.subject}
|
|
693
|
-
</p>
|
|
400
|
+
<p className="line-clamp-2 text-sm font-semibold">{item.subject}</p>
|
|
694
401
|
</div>
|
|
695
|
-
<p className="line-clamp-2 text-xs text-muted-foreground">
|
|
696
|
-
{item.notes}
|
|
697
|
-
</p>
|
|
402
|
+
<p className="line-clamp-2 text-xs text-muted-foreground">{item.notes || t('detail.emptyNotes')}</p>
|
|
698
403
|
</div>
|
|
699
|
-
<Badge
|
|
700
|
-
variant="outline"
|
|
701
|
-
className={cn(
|
|
702
|
-
'shrink-0 border',
|
|
703
|
-
statusBadgeClass(item.status)
|
|
704
|
-
)}
|
|
705
|
-
>
|
|
706
|
-
{t(`status.${item.status}`)}
|
|
707
|
-
</Badge>
|
|
404
|
+
<Badge variant="outline" className={cn('shrink-0 border', getStatusBadgeClass(item.status))}>{t(`status.${item.status}`)}</Badge>
|
|
708
405
|
</div>
|
|
709
|
-
|
|
710
406
|
<div className="grid gap-2 rounded-md border border-border/70 bg-background px-3 py-2 text-xs">
|
|
711
|
-
<div className="flex items-center justify-between gap-2">
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
<span className="font-medium text-foreground">
|
|
716
|
-
{formatDateTime(
|
|
717
|
-
item.dueAt,
|
|
718
|
-
getSettingValue,
|
|
719
|
-
currentLocaleCode
|
|
720
|
-
)}
|
|
721
|
-
</span>
|
|
722
|
-
</div>
|
|
723
|
-
<div className="flex items-center justify-between gap-2">
|
|
724
|
-
<span className="text-muted-foreground">
|
|
725
|
-
{t('timeline.createdLabel')}
|
|
726
|
-
</span>
|
|
727
|
-
<span className="font-medium text-foreground">
|
|
728
|
-
{formatDateTime(
|
|
729
|
-
item.createdAt,
|
|
730
|
-
getSettingValue,
|
|
731
|
-
currentLocaleCode
|
|
732
|
-
)}
|
|
733
|
-
</span>
|
|
734
|
-
</div>
|
|
735
|
-
<div className="flex items-center justify-between gap-2">
|
|
736
|
-
<span className="text-muted-foreground">
|
|
737
|
-
{t('table.person')}
|
|
738
|
-
</span>
|
|
739
|
-
<span className="truncate font-medium text-foreground">
|
|
740
|
-
{item.personName}
|
|
741
|
-
</span>
|
|
742
|
-
</div>
|
|
743
|
-
<div className="flex items-center justify-between gap-2">
|
|
744
|
-
<span className="text-muted-foreground">
|
|
745
|
-
{t('table.owner')}
|
|
746
|
-
</span>
|
|
747
|
-
<span className="truncate font-medium text-foreground">
|
|
748
|
-
{item.ownerName}
|
|
749
|
-
</span>
|
|
750
|
-
</div>
|
|
407
|
+
<div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('timeline.dueLabel')}</span><span className="font-medium text-foreground">{formatDateTime(item.due_at, getSettingValue, currentLocaleCode)}</span></div>
|
|
408
|
+
<div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('timeline.createdLabel')}</span><span className="font-medium text-foreground">{formatDateTime(item.created_at, getSettingValue, currentLocaleCode)}</span></div>
|
|
409
|
+
<div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('table.person')}</span><span className="truncate font-medium text-foreground">{item.person.name}</span></div>
|
|
410
|
+
<div className="flex items-center justify-between gap-2"><span className="text-muted-foreground">{t('table.owner')}</span><span className="truncate font-medium text-foreground">{item.owner_user?.name || t('unassignedOwner')}</span></div>
|
|
751
411
|
</div>
|
|
752
|
-
|
|
753
412
|
<div className="flex items-center justify-between gap-2">
|
|
754
|
-
<Badge
|
|
755
|
-
variant="outline"
|
|
756
|
-
className={cn(
|
|
757
|
-
'border',
|
|
758
|
-
priorityBadgeClass(item.priority)
|
|
759
|
-
)}
|
|
760
|
-
>
|
|
761
|
-
{t(`priority.${item.priority}`)}
|
|
762
|
-
</Badge>
|
|
763
|
-
|
|
413
|
+
<Badge variant="outline" className={cn('border', getPriorityBadgeClass(item.priority))}>{t(`priority.${item.priority}`)}</Badge>
|
|
764
414
|
<div className="inline-flex items-center gap-2">
|
|
765
|
-
<Button
|
|
766
|
-
type="button"
|
|
767
|
-
variant="outline"
|
|
768
|
-
size="sm"
|
|
769
|
-
onClick={() => handleView(item.subject)}
|
|
770
|
-
>
|
|
415
|
+
<Button type="button" variant="outline" size="sm" onClick={() => { setSelectedActivityId(item.id); setDetailOpen(true); }}>
|
|
771
416
|
<Eye className="mr-2 h-3.5 w-3.5" />
|
|
772
417
|
{t('actions.view')}
|
|
773
418
|
</Button>
|
|
774
|
-
<Button
|
|
775
|
-
|
|
776
|
-
variant="outline"
|
|
777
|
-
size="sm"
|
|
778
|
-
onClick={() => handleComplete(item.id)}
|
|
779
|
-
disabled={completed}
|
|
780
|
-
className={cn(
|
|
781
|
-
completed && 'cursor-not-allowed opacity-60'
|
|
782
|
-
)}
|
|
783
|
-
>
|
|
784
|
-
<CheckCircle2 className="mr-2 h-3.5 w-3.5" />
|
|
419
|
+
<Button type="button" variant="outline" size="sm" onClick={() => handleComplete(item.id)} disabled={isCompleted || isCompleting} className={cn((isCompleted || isCompleting) && 'cursor-not-allowed opacity-60')}>
|
|
420
|
+
{isCompleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-2 h-3.5 w-3.5" />}
|
|
785
421
|
{t('actions.complete')}
|
|
786
422
|
</Button>
|
|
787
423
|
</div>
|
|
@@ -796,9 +432,9 @@ export default function CrmActivitiesPage() {
|
|
|
796
432
|
|
|
797
433
|
<div className="border-t p-4">
|
|
798
434
|
<PaginationFooter
|
|
799
|
-
currentPage={page}
|
|
800
|
-
pageSize={pageSize}
|
|
801
|
-
totalItems={
|
|
435
|
+
currentPage={paginate.page}
|
|
436
|
+
pageSize={paginate.pageSize}
|
|
437
|
+
totalItems={paginate.total}
|
|
802
438
|
onPageChange={setPage}
|
|
803
439
|
onPageSizeChange={(nextPageSize) => {
|
|
804
440
|
setPageSize(nextPageSize);
|
|
@@ -807,6 +443,18 @@ export default function CrmActivitiesPage() {
|
|
|
807
443
|
/>
|
|
808
444
|
</div>
|
|
809
445
|
</div>
|
|
446
|
+
|
|
447
|
+
<ActivityDetailSheet
|
|
448
|
+
activityId={selectedActivityId}
|
|
449
|
+
open={detailOpen}
|
|
450
|
+
refreshKey={detailRefreshKey}
|
|
451
|
+
isCompleting={selectedActivityId === completingActivityId}
|
|
452
|
+
onOpenChange={(open) => {
|
|
453
|
+
setDetailOpen(open);
|
|
454
|
+
if (!open) setSelectedActivityId(null);
|
|
455
|
+
}}
|
|
456
|
+
onComplete={handleComplete}
|
|
457
|
+
/>
|
|
810
458
|
</Page>
|
|
811
459
|
);
|
|
812
460
|
}
|