@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.
Files changed (46) hide show
  1. package/dist/person/dto/account.dto.d.ts +28 -0
  2. package/dist/person/dto/account.dto.d.ts.map +1 -0
  3. package/dist/person/dto/account.dto.js +123 -0
  4. package/dist/person/dto/account.dto.js.map +1 -0
  5. package/dist/person/dto/activity.dto.d.ts +15 -0
  6. package/dist/person/dto/activity.dto.d.ts.map +1 -0
  7. package/dist/person/dto/activity.dto.js +65 -0
  8. package/dist/person/dto/activity.dto.js.map +1 -0
  9. package/dist/person/dto/dashboard-query.dto.d.ts +9 -0
  10. package/dist/person/dto/dashboard-query.dto.d.ts.map +1 -0
  11. package/dist/person/dto/dashboard-query.dto.js +40 -0
  12. package/dist/person/dto/dashboard-query.dto.js.map +1 -0
  13. package/dist/person/dto/followup-query.dto.d.ts +10 -0
  14. package/dist/person/dto/followup-query.dto.d.ts.map +1 -0
  15. package/dist/person/dto/followup-query.dto.js +45 -0
  16. package/dist/person/dto/followup-query.dto.js.map +1 -0
  17. package/dist/person/person.controller.d.ts +204 -0
  18. package/dist/person/person.controller.d.ts.map +1 -1
  19. package/dist/person/person.controller.js +138 -0
  20. package/dist/person/person.controller.js.map +1 -1
  21. package/dist/person/person.service.d.ts +234 -0
  22. package/dist/person/person.service.d.ts.map +1 -1
  23. package/dist/person/person.service.js +1367 -0
  24. package/dist/person/person.service.js.map +1 -1
  25. package/hedhog/data/menu.yaml +163 -163
  26. package/hedhog/data/route.yaml +41 -0
  27. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +210 -114
  28. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +3 -0
  29. package/hedhog/frontend/app/accounts/page.tsx.ejs +323 -245
  30. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
  31. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
  32. package/hedhog/frontend/app/activities/page.tsx.ejs +165 -517
  33. package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
  34. package/hedhog/frontend/app/dashboard/page.tsx.ejs +504 -356
  35. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +242 -153
  36. package/hedhog/frontend/messages/en.json +91 -6
  37. package/hedhog/frontend/messages/pt.json +91 -6
  38. package/hedhog/table/crm_activity.yaml +68 -0
  39. package/hedhog/table/person_company.yaml +22 -0
  40. package/package.json +4 -4
  41. package/src/person/dto/account.dto.ts +100 -0
  42. package/src/person/dto/activity.dto.ts +54 -0
  43. package/src/person/dto/dashboard-query.dto.ts +25 -0
  44. package/src/person/dto/followup-query.dto.ts +25 -0
  45. package/src/person/person.controller.ts +116 -0
  46. 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, useMemo, useState } from 'react';
47
+ import { useEffect, useState } from 'react';
46
48
  import { toast } from 'sonner';
47
- import { crmMockLeads, crmOwners } from '../_lib/crm-mocks';
48
-
49
- type ActivityType = 'call' | 'email' | 'meeting' | 'whatsapp' | 'task' | 'note';
50
- type ActivityPriority = 'high' | 'medium' | 'low';
51
- type ActivityStatus = 'pending' | 'overdue' | 'completed';
52
- type ActivityViewMode = 'table' | 'timeline';
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 statusBadgeClass(status: ActivityStatus) {
214
- if (status === 'completed') {
215
- return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700';
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 priorityBadgeClass(priority: ActivityPriority) {
226
- if (priority === 'high') {
227
- return 'border-red-500/25 bg-red-500/10 text-red-700';
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 enrichedActivities = useMemo(() => {
274
- return activities
275
- .map((activity) => {
276
- const lead = crmMockLeads.find((item) => item.id === activity.personId);
277
- const owner = crmOwners.find((item) => item.id === activity.ownerId);
278
-
279
- return {
280
- ...activity,
281
- personName: lead?.name || t('unassignedPerson'),
282
- ownerName: owner?.name || t('unassignedOwner'),
283
- status: resolveActivityStatus(activity),
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
- return { total, pending, overdue, completed };
358
- }, [enrichedActivities]);
359
-
360
- const statsCards = [
361
- {
362
- key: 'total',
363
- title: t('stats.total'),
364
- value: stats.total,
365
- icon: Activity,
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
- key: 'pending',
371
- title: t('stats.pending'),
372
- value: stats.pending,
373
- icon: Clock3,
374
- accentClassName: 'from-amber-500/20 via-yellow-500/10 to-transparent',
375
- iconContainerClassName: 'bg-amber-500/10 text-amber-700',
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
- key: 'overdue',
379
- title: t('stats.overdue'),
380
- value: stats.overdue,
381
- icon: CircleAlert,
382
- accentClassName: 'from-red-500/20 via-rose-500/10 to-transparent',
383
- iconContainerClassName: 'bg-red-500/10 text-red-700',
384
- },
385
- {
386
- key: 'completed',
387
- title: t('stats.completed'),
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 handleComplete = (activityId: number) => {
450
- setActivities((previous) =>
451
- previous.map((item) =>
452
- item.id === activityId
453
- ? {
454
- ...item,
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
- {t('viewMode')}
511
- </span>
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
- {pageData.length === 0 ? (
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
- {pageData.map((item) => {
342
+ {paginate.data.map((item) => {
572
343
  const TypeIcon = getTypeIcon(item.type);
573
- const completed = item.status === 'completed';
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
- <div className="inline-flex items-center gap-2">
598
- <Users className="h-3.5 w-3.5 text-muted-foreground" />
599
- <span>{item.personName}</span>
600
- </div>
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
- <Button
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
- {pageData.map((item) => {
388
+ {paginate.data.map((item) => {
678
389
  const TypeIcon = getTypeIcon(item.type);
679
- const completed = item.status === 'completed';
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
- <span className="text-muted-foreground">
713
- {t('timeline.dueLabel')}
714
- </span>
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
- type="button"
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={filteredActivities.length}
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
  }