@hed-hog/contact 0.0.293 → 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 +5 -5
  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
@@ -21,7 +21,6 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
21
21
  import { Badge } from '@/components/ui/badge';
22
22
  import { Button } from '@/components/ui/button';
23
23
  import { Card, CardContent } from '@/components/ui/card';
24
- import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
25
24
  import {
26
25
  DropdownMenu,
27
26
  DropdownMenuContent,
@@ -29,6 +28,7 @@ import {
29
28
  DropdownMenuSeparator,
30
29
  DropdownMenuTrigger,
31
30
  } from '@/components/ui/dropdown-menu';
31
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
32
32
  import { Skeleton } from '@/components/ui/skeleton';
33
33
  import {
34
34
  Table,
@@ -41,12 +41,12 @@ import {
41
41
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
42
42
  import { formatDate } from '@/lib/format-date';
43
43
  import { cn } from '@/lib/utils';
44
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
44
45
  import {
45
46
  type ColumnDef,
46
47
  type SortingState,
47
48
  flexRender,
48
49
  getCoreRowModel,
49
- getSortedRowModel,
50
50
  useReactTable,
51
51
  } from '@tanstack/react-table';
52
52
  import {
@@ -65,18 +65,23 @@ import {
65
65
  import { useTranslations } from 'next-intl';
66
66
  import { type ReactNode, useEffect, useMemo, useState } from 'react';
67
67
  import { toast } from 'sonner';
68
- import { CrmNav } from '../_components/crm-nav';
69
- import { crmMockAccounts, crmOwners } from '../_lib/crm-mocks';
70
68
  import { AccountFormSheet } from './_components/account-form-sheet';
71
69
  import type {
72
70
  Account,
73
71
  AccountFormValues,
74
72
  AccountStats,
73
+ PaginatedResult,
74
+ UserOption,
75
75
  } from './_components/account-types';
76
76
 
77
77
  const ACCOUNT_VIEW_STORAGE_KEY = 'contact-account-view-mode';
78
+
78
79
  type AccountViewMode = 'table' | 'cards';
79
80
 
81
+ type RequestError = {
82
+ message?: string;
83
+ };
84
+
80
85
  function getAccountInitials(name: string) {
81
86
  return name
82
87
  .split(' ')
@@ -122,11 +127,7 @@ function AccountInfoTile({
122
127
  )}
123
128
  >
124
129
  <div className="mb-2 flex items-center gap-2">
125
- <div
126
- className={cn(
127
- 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground'
128
- )}
129
- >
130
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
130
131
  {icon}
131
132
  </div>
132
133
  <span className="truncate text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
@@ -149,6 +150,7 @@ function AccountInfoTile({
149
150
  export default function AccountsPage() {
150
151
  const t = useTranslations('contact.AccountsPage');
151
152
  const crmT = useTranslations('contact.CrmMenu');
153
+ const { request, currentLocaleCode, getSettingValue } = useApp();
152
154
 
153
155
  const [sorting, setSorting] = useState<SortingState>([]);
154
156
  const [page, setPage] = useState(1);
@@ -162,25 +164,14 @@ export default function AccountsPage() {
162
164
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
163
165
  const [accountToDelete, setAccountToDelete] = useState<Account | null>(null);
164
166
  const [isDeleting, setIsDeleting] = useState(false);
167
+ const [isSubmitting, setIsSubmitting] = useState(false);
165
168
  const [viewMode, setViewMode] = useState<AccountViewMode>('table');
166
- const [accounts, setAccounts] = useState<Account[]>([]);
167
- const [isLoading, setIsLoading] = useState(true);
168
-
169
- // Simulated API calls with mock data
170
- useEffect(() => {
171
- setIsLoading(true);
172
- // Simulate API call delay
173
- const timeout = setTimeout(() => {
174
- setAccounts(crmMockAccounts);
175
- setIsLoading(false);
176
- }, 300);
177
- return () => clearTimeout(timeout);
178
- }, []);
179
169
 
180
170
  useEffect(() => {
181
171
  const timeout = setTimeout(() => {
182
172
  setDebouncedSearch(searchInput.trim());
183
173
  }, 300);
174
+
184
175
  return () => clearTimeout(timeout);
185
176
  }, [searchInput]);
186
177
 
@@ -193,62 +184,125 @@ export default function AccountsPage() {
193
184
  setViewMode(savedViewMode);
194
185
  }
195
186
  } catch {
196
- // Ignore storage read failures
187
+ // Ignore storage read failures.
197
188
  }
198
189
  }, []);
199
190
 
200
- // Filter and paginate data
201
- const filteredAccounts = useMemo(() => {
202
- let result = accounts;
203
-
204
- if (debouncedSearch) {
205
- const search = debouncedSearch.toLowerCase();
206
- result = result.filter(
207
- (account) =>
208
- account.name.toLowerCase().includes(search) ||
209
- account.trade_name?.toLowerCase().includes(search) ||
210
- account.email?.toLowerCase().includes(search) ||
211
- account.city?.toLowerCase().includes(search)
212
- );
213
- }
191
+ const currentSort = sorting[0];
192
+ const sortField = currentSort?.id === 'created_at' ? 'created_at' : 'name';
193
+ const sortOrder = currentSort?.desc ? 'desc' : 'asc';
214
194
 
215
- if (statusFilter !== 'all') {
216
- result = result.filter((account) => account.status === statusFilter);
217
- }
195
+ const {
196
+ data: stats = {
197
+ total: 0,
198
+ active: 0,
199
+ customers: 0,
200
+ prospects: 0,
201
+ },
202
+ refetch: refetchStats,
203
+ } = useQuery<AccountStats>({
204
+ queryKey: ['contact-accounts-stats', currentLocaleCode],
205
+ queryFn: async () => {
206
+ const response = await request<AccountStats>({
207
+ url: '/person/accounts/stats',
208
+ method: 'GET',
209
+ });
210
+
211
+ return response.data;
212
+ },
213
+ placeholderData: (previous) =>
214
+ previous ?? {
215
+ total: 0,
216
+ active: 0,
217
+ customers: 0,
218
+ prospects: 0,
219
+ },
220
+ });
218
221
 
219
- if (lifecycleFilter !== 'all') {
220
- result = result.filter(
221
- (account) => account.lifecycle_stage === lifecycleFilter
222
- );
223
- }
222
+ const { data: owners = [] } = useQuery<UserOption[]>({
223
+ queryKey: ['contact-account-owner-options', currentLocaleCode],
224
+ queryFn: async () => {
225
+ const response = await request<UserOption[] | { data?: UserOption[] }>({
226
+ url: '/person/owner-options',
227
+ method: 'GET',
228
+ });
229
+
230
+ return Array.isArray(response.data)
231
+ ? response.data
232
+ : response.data?.data || [];
233
+ },
234
+ placeholderData: (previous) => previous ?? [],
235
+ });
224
236
 
225
- return result;
226
- }, [accounts, debouncedSearch, statusFilter, lifecycleFilter]);
237
+ const {
238
+ data: paginate = {
239
+ data: [],
240
+ total: 0,
241
+ page: 1,
242
+ pageSize: 12,
243
+ lastPage: 1,
244
+ prev: null,
245
+ next: null,
246
+ },
247
+ isLoading,
248
+ refetch: refetchAccounts,
249
+ } = useQuery<PaginatedResult<Account>>({
250
+ queryKey: [
251
+ 'contact-accounts',
252
+ page,
253
+ pageSize,
254
+ debouncedSearch,
255
+ statusFilter,
256
+ lifecycleFilter,
257
+ sortField,
258
+ sortOrder,
259
+ currentLocaleCode,
260
+ ],
261
+ queryFn: async () => {
262
+ const params = new URLSearchParams();
263
+ params.set('page', String(page));
264
+ params.set('pageSize', String(pageSize));
265
+ params.set('sortField', sortField);
266
+ params.set('sortOrder', sortOrder);
267
+ if (debouncedSearch) {
268
+ params.set('search', debouncedSearch);
269
+ }
270
+ if (statusFilter !== 'all') {
271
+ params.set('status', statusFilter);
272
+ }
273
+ if (lifecycleFilter !== 'all') {
274
+ params.set('lifecycle_stage', lifecycleFilter);
275
+ }
276
+
277
+ const response = await request<PaginatedResult<Account>>({
278
+ url: `/person/accounts?${params.toString()}`,
279
+ method: 'GET',
280
+ });
281
+
282
+ return response.data;
283
+ },
284
+ placeholderData: (previous) =>
285
+ previous ?? {
286
+ data: [],
287
+ total: 0,
288
+ page: 1,
289
+ pageSize: 12,
290
+ lastPage: 1,
291
+ prev: null,
292
+ next: null,
293
+ },
294
+ });
227
295
 
228
- const pageCount = Math.max(1, Math.ceil(filteredAccounts.length / pageSize));
296
+ const totalPages = Math.max(
297
+ 1,
298
+ paginate.lastPage ?? (Math.ceil((paginate.total || 0) / pageSize) || 1)
299
+ );
229
300
 
230
301
  useEffect(() => {
231
- if (page > pageCount) {
232
- setPage(pageCount);
302
+ if (page > totalPages) {
303
+ setPage(totalPages);
233
304
  }
234
- }, [page, pageCount]);
235
-
236
- const paginatedAccounts = useMemo(() => {
237
- const startIndex = (page - 1) * pageSize;
238
- return filteredAccounts.slice(startIndex, startIndex + pageSize);
239
- }, [filteredAccounts, page, pageSize]);
240
-
241
- // Calculate stats
242
- const stats: AccountStats = useMemo(() => {
243
- return {
244
- total: accounts.length,
245
- active: accounts.filter((a) => a.status === 'active').length,
246
- customers: accounts.filter((a) => a.lifecycle_stage === 'customer')
247
- .length,
248
- prospects: accounts.filter((a) => a.lifecycle_stage === 'prospect')
249
- .length,
250
- };
251
- }, [accounts]);
305
+ }, [page, totalPages]);
252
306
 
253
307
  const openCreateSheet = () => {
254
308
  setAccountToEdit(null);
@@ -260,56 +314,79 @@ export default function AccountsPage() {
260
314
  setFormSheetOpen(true);
261
315
  };
262
316
 
263
- const handleFormSubmit = (data: AccountFormValues) => {
264
- if (accountToEdit) {
265
- // Edit mode - update existing account
266
- setAccounts((prev) =>
267
- prev.map((acc) =>
268
- acc.id === accountToEdit.id
269
- ? {
270
- ...acc,
271
- ...data,
272
- }
273
- : acc
274
- )
275
- );
276
- toast.success(t('editSuccess'));
277
- } else {
278
- // Create mode - add new account
279
- const newAccount: Account = {
280
- id: Math.max(...accounts.map((a) => a.id), 0) + 1,
281
- ...data,
282
- created_at: new Date().toISOString(),
283
- };
284
- setAccounts((prev) => [newAccount, ...prev]);
285
- toast.success(t('createSuccess'));
317
+ const handleFormSubmit = async (data: AccountFormValues) => {
318
+ try {
319
+ setIsSubmitting(true);
320
+
321
+ if (accountToEdit) {
322
+ await request({
323
+ url: `/person/accounts/${accountToEdit.id}`,
324
+ method: 'PATCH',
325
+ data,
326
+ });
327
+ toast.success(t('editSuccess'));
328
+ } else {
329
+ await request({
330
+ url: '/person/accounts',
331
+ method: 'POST',
332
+ data,
333
+ });
334
+ toast.success(t('createSuccess'));
335
+ }
336
+
337
+ setFormSheetOpen(false);
338
+ setAccountToEdit(null);
339
+ await Promise.all([refetchAccounts(), refetchStats()]);
340
+ } catch (error: unknown) {
341
+ const message =
342
+ typeof error === 'object' && error && 'message' in error
343
+ ? (error as RequestError).message
344
+ : null;
345
+ toast.error(message || t('saveError'));
346
+ throw error;
347
+ } finally {
348
+ setIsSubmitting(false);
286
349
  }
287
- setFormSheetOpen(false);
288
350
  };
289
351
 
290
- const handleDelete = () => {
352
+ const handleDelete = async () => {
291
353
  if (!accountToDelete) return;
292
- setIsDeleting(true);
293
- setTimeout(() => {
294
- setAccounts((prev) =>
295
- prev.filter((acc) => acc.id !== accountToDelete.id)
296
- );
354
+
355
+ try {
356
+ setIsDeleting(true);
357
+ await request({
358
+ url: '/person/accounts',
359
+ method: 'DELETE',
360
+ data: {
361
+ ids: [accountToDelete.id],
362
+ },
363
+ });
364
+
297
365
  toast.success(t('deleteSuccess'));
298
366
  setDeleteDialogOpen(false);
299
367
  setAccountToDelete(null);
368
+ await Promise.all([refetchAccounts(), refetchStats()]);
369
+ } catch (error: unknown) {
370
+ const message =
371
+ typeof error === 'object' && error && 'message' in error
372
+ ? (error as RequestError).message
373
+ : null;
374
+ toast.error(message || t('deleteError'));
375
+ } finally {
300
376
  setIsDeleting(false);
301
- }, 300);
377
+ }
302
378
  };
303
379
 
304
380
  const handleViewModeChange = (value: string) => {
305
381
  if (value !== 'table' && value !== 'cards') {
306
382
  return;
307
383
  }
384
+
308
385
  setViewMode(value);
309
386
  try {
310
387
  window.localStorage.setItem(ACCOUNT_VIEW_STORAGE_KEY, value);
311
388
  } catch {
312
- // Ignore storage write failures
389
+ // Ignore storage write failures.
313
390
  }
314
391
  };
315
392
 
@@ -320,7 +397,10 @@ export default function AccountsPage() {
320
397
  header: ({ column }) => (
321
398
  <Button
322
399
  variant="ghost"
323
- onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
400
+ onClick={() => {
401
+ column.toggleSorting(column.getIsSorted() === 'asc');
402
+ setPage(1);
403
+ }}
324
404
  className="-ml-4"
325
405
  >
326
406
  {t('columnName')}
@@ -332,7 +412,7 @@ export default function AccountsPage() {
332
412
  return (
333
413
  <div className="flex items-center gap-3">
334
414
  <Avatar className="h-9 w-9 rounded-lg">
335
- <AvatarFallback className="bg-slate-100 rounded-lg text-xs font-semibold uppercase text-slate-700">
415
+ <AvatarFallback className="rounded-lg bg-slate-100 text-xs font-semibold uppercase text-slate-700">
336
416
  {getAccountInitials(account.name)}
337
417
  </AvatarFallback>
338
418
  </Avatar>
@@ -353,7 +433,7 @@ export default function AccountsPage() {
353
433
  header: t('columnIndustry'),
354
434
  cell: ({ row }) => (
355
435
  <span className="text-sm text-muted-foreground">
356
- {row.getValue('industry') || '-'}
436
+ {(row.getValue('industry') as string | null) || '-'}
357
437
  </span>
358
438
  ),
359
439
  },
@@ -361,7 +441,7 @@ export default function AccountsPage() {
361
441
  accessorKey: 'lifecycle_stage',
362
442
  header: t('columnStage'),
363
443
  cell: ({ row }) => {
364
- const stage = row.getValue('lifecycle_stage') as string;
444
+ const stage = row.getValue('lifecycle_stage') as string | null;
365
445
  return (
366
446
  <Badge
367
447
  variant="outline"
@@ -370,7 +450,7 @@ export default function AccountsPage() {
370
450
  getLifecycleStageColor(stage)
371
451
  )}
372
452
  >
373
- {t(`stage_${stage}` as any) || stage}
453
+ {stage ? t(`stage_${stage}` as never) : '-'}
374
454
  </Badge>
375
455
  );
376
456
  },
@@ -390,7 +470,7 @@ export default function AccountsPage() {
390
470
  : 'border-red-500/20 bg-red-500/10 text-red-600'
391
471
  )}
392
472
  >
393
- {t(`status_${status}` as any) || status}
473
+ {t(`status_${status}` as never)}
394
474
  </Badge>
395
475
  );
396
476
  },
@@ -412,7 +492,7 @@ export default function AccountsPage() {
412
492
  header: t('columnCity'),
413
493
  cell: ({ row }) => (
414
494
  <span className="text-sm text-muted-foreground">
415
- {row.getValue('city') || '-'}
495
+ {(row.getValue('city') as string | null) || '-'}
416
496
  </span>
417
497
  ),
418
498
  },
@@ -421,7 +501,10 @@ export default function AccountsPage() {
421
501
  header: ({ column }) => (
422
502
  <Button
423
503
  variant="ghost"
424
- onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
504
+ onClick={() => {
505
+ column.toggleSorting(column.getIsSorted() === 'asc');
506
+ setPage(1);
507
+ }}
425
508
  className="-ml-4"
426
509
  >
427
510
  {t('columnCreatedAt')}
@@ -430,7 +513,11 @@ export default function AccountsPage() {
430
513
  ),
431
514
  cell: ({ row }) => (
432
515
  <span className="text-sm text-muted-foreground">
433
- {formatDate(row.getValue('created_at') as string, () => {}, 'en')}
516
+ {formatDate(
517
+ row.getValue('created_at') as string,
518
+ getSettingValue,
519
+ currentLocaleCode
520
+ )}
434
521
  </span>
435
522
  ),
436
523
  },
@@ -467,16 +554,16 @@ export default function AccountsPage() {
467
554
  },
468
555
  },
469
556
  ],
470
- [t]
557
+ [currentLocaleCode, getSettingValue, t]
471
558
  );
472
559
 
473
560
  const table = useReactTable({
474
- data: paginatedAccounts,
561
+ data: paginate.data,
475
562
  columns,
476
563
  state: { sorting },
564
+ manualSorting: true,
477
565
  onSortingChange: setSorting,
478
566
  getCoreRowModel: getCoreRowModel(),
479
- getSortedRowModel: getSortedRowModel(),
480
567
  });
481
568
 
482
569
  const accountsRows = table.getRowModel().rows;
@@ -519,7 +606,7 @@ export default function AccountsPage() {
519
606
  const searchControls: SearchBarControl[] = [
520
607
  {
521
608
  id: 'lifecycle-filter',
522
- type: 'select' as const,
609
+ type: 'select',
523
610
  value: lifecycleFilter,
524
611
  onChange: (value: string) => {
525
612
  setLifecycleFilter(value);
@@ -580,9 +667,7 @@ export default function AccountsPage() {
580
667
  setSearchInput(value);
581
668
  setPage(1);
582
669
  }}
583
- onSearch={() => {
584
- setPage(1);
585
- }}
670
+ onSearch={() => setPage(1)}
586
671
  placeholder={t('searchPlaceholder')}
587
672
  controls={searchControls}
588
673
  />
@@ -653,7 +738,7 @@ export default function AccountsPage() {
653
738
  ))}
654
739
  </div>
655
740
  )
656
- ) : paginatedAccounts.length === 0 ? (
741
+ ) : paginate.data.length === 0 ? (
657
742
  <EmptyState
658
743
  icon={<Building2 className="h-12 w-12" />}
659
744
  title={t('emptyStateTitle')}
@@ -705,147 +790,139 @@ export default function AccountsPage() {
705
790
  </div>
706
791
  ) : (
707
792
  <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
708
- {accountsRows.map((row) => {
709
- const account = row.original;
710
-
711
- return (
712
- <Card
713
- key={account.id}
714
- className={cn(
715
- 'group h-full overflow-hidden border-border/70 py-0 transition-colors hover:border-border hover:shadow-md'
716
- )}
717
- onDoubleClick={() => openEditSheet(account)}
718
- >
719
- <CardContent className="flex h-full flex-col gap-3 p-4">
720
- <div className="flex items-start justify-between gap-3">
721
- <div className="flex min-w-0 items-start gap-2.5">
722
- <Avatar className="h-10 w-10 shrink-0 rounded-lg border border-slate-500/20">
723
- <AvatarFallback className="rounded-lg bg-slate-500/8 text-sm font-semibold uppercase text-slate-700 dark:text-slate-200">
724
- {getAccountInitials(account.name)}
725
- </AvatarFallback>
726
- </Avatar>
727
-
728
- <div className="min-w-0 space-y-1.5">
729
- <div className="flex flex-wrap gap-1.5">
730
- <Badge
731
- variant="outline"
732
- className={cn(
733
- 'border px-2 py-0.5 text-[11px] font-medium',
734
- getLifecycleStageColor(
735
- account.lifecycle_stage
736
- )
737
- )}
738
- >
739
- {t(`stage_${account.lifecycle_stage}` as any) ||
740
- account.lifecycle_stage}
741
- </Badge>
742
-
743
- <Badge
744
- variant="outline"
745
- className={cn(
746
- 'border px-2 py-0.5 text-[11px] font-medium',
747
- account.status === 'active'
748
- ? 'border-green-500/20 bg-green-500/10 text-green-600'
749
- : 'border-red-500/20 bg-red-500/10 text-red-600'
750
- )}
751
- >
752
- {t(`status_${account.status}` as any) ||
753
- account.status}
754
- </Badge>
755
- </div>
756
-
757
- <div className="min-w-0">
758
- <h3 className="line-clamp-2 text-sm font-semibold text-foreground">
759
- {account.name}
760
- </h3>
761
- {account.trade_name && (
762
- <p className="truncate text-xs text-muted-foreground">
763
- {account.trade_name}
764
- </p>
793
+ {paginate.data.map((account) => (
794
+ <Card
795
+ key={account.id}
796
+ className="group h-full overflow-hidden border-border/70 py-0 transition-colors hover:border-border hover:shadow-md"
797
+ onDoubleClick={() => openEditSheet(account)}
798
+ >
799
+ <CardContent className="flex h-full flex-col gap-3 p-4">
800
+ <div className="flex items-start justify-between gap-3">
801
+ <div className="flex min-w-0 items-start gap-2.5">
802
+ <Avatar className="h-10 w-10 shrink-0 rounded-lg border border-slate-500/20">
803
+ <AvatarFallback className="rounded-lg bg-slate-500/8 text-sm font-semibold uppercase text-slate-700 dark:text-slate-200">
804
+ {getAccountInitials(account.name)}
805
+ </AvatarFallback>
806
+ </Avatar>
807
+
808
+ <div className="min-w-0 space-y-1.5">
809
+ <div className="flex flex-wrap gap-1.5">
810
+ <Badge
811
+ variant="outline"
812
+ className={cn(
813
+ 'border px-2 py-0.5 text-[11px] font-medium',
814
+ getLifecycleStageColor(account.lifecycle_stage)
765
815
  )}
766
- {account.industry && (
767
- <p className="truncate text-xs text-muted-foreground">
768
- {account.industry}
769
- </p>
816
+ >
817
+ {account.lifecycle_stage
818
+ ? t(`stage_${account.lifecycle_stage}` as never)
819
+ : '-'}
820
+ </Badge>
821
+
822
+ <Badge
823
+ variant="outline"
824
+ className={cn(
825
+ 'border px-2 py-0.5 text-[11px] font-medium',
826
+ account.status === 'active'
827
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
828
+ : 'border-red-500/20 bg-red-500/10 text-red-600'
770
829
  )}
771
- </div>
830
+ >
831
+ {t(`status_${account.status}` as never)}
832
+ </Badge>
772
833
  </div>
773
- </div>
774
834
 
775
- <DropdownMenu>
776
- <DropdownMenuTrigger asChild>
777
- <Button
778
- variant="ghost"
779
- size="icon"
780
- className="h-8 w-8 rounded-lg"
781
- >
782
- <MoreHorizontal className="h-4 w-4" />
783
- </Button>
784
- </DropdownMenuTrigger>
785
- <DropdownMenuContent align="end">
786
- <DropdownMenuItem
787
- onClick={() => openEditSheet(account)}
788
- >
789
- <Pencil className="mr-2 h-4 w-4" />
790
- {t('edit')}
791
- </DropdownMenuItem>
792
- <DropdownMenuSeparator />
793
- <DropdownMenuItem
794
- className="text-red-600"
795
- onClick={() => {
796
- setAccountToDelete(account);
797
- setDeleteDialogOpen(true);
798
- }}
799
- >
800
- <Trash2 className="mr-2 h-4 w-4" />
801
- {t('delete')}
802
- </DropdownMenuItem>
803
- </DropdownMenuContent>
804
- </DropdownMenu>
835
+ <div className="min-w-0">
836
+ <h3 className="line-clamp-2 text-sm font-semibold text-foreground">
837
+ {account.name}
838
+ </h3>
839
+ {account.trade_name && (
840
+ <p className="truncate text-xs text-muted-foreground">
841
+ {account.trade_name}
842
+ </p>
843
+ )}
844
+ {account.industry && (
845
+ <p className="truncate text-xs text-muted-foreground">
846
+ {account.industry}
847
+ </p>
848
+ )}
849
+ </div>
850
+ </div>
805
851
  </div>
806
852
 
807
- <div className="grid gap-3 sm:grid-cols-2">
808
- <AccountInfoTile
809
- icon={<Mail className="h-3 w-3" />}
810
- label={t('tileEmail')}
811
- value={account.email || '-'}
812
- />
813
- <AccountInfoTile
814
- icon={<Phone className="h-3 w-3" />}
815
- label={t('tilePhone')}
816
- value={account.phone || '-'}
817
- />
818
- <AccountInfoTile
819
- icon={<Globe className="h-3 w-3" />}
820
- label={t('tileWebsite')}
821
- value={account.website || '-'}
822
- />
823
- <AccountInfoTile
824
- icon={<Building2 className="h-3 w-3" />}
825
- label={t('tileIndustry')}
826
- value={account.industry || '-'}
827
- />
828
- </div>
853
+ <DropdownMenu>
854
+ <DropdownMenuTrigger asChild>
855
+ <Button
856
+ variant="ghost"
857
+ size="icon"
858
+ className="h-8 w-8 rounded-lg"
859
+ >
860
+ <MoreHorizontal className="h-4 w-4" />
861
+ </Button>
862
+ </DropdownMenuTrigger>
863
+ <DropdownMenuContent align="end">
864
+ <DropdownMenuItem
865
+ onClick={() => openEditSheet(account)}
866
+ >
867
+ <Pencil className="mr-2 h-4 w-4" />
868
+ {t('edit')}
869
+ </DropdownMenuItem>
870
+ <DropdownMenuSeparator />
871
+ <DropdownMenuItem
872
+ className="text-red-600"
873
+ onClick={() => {
874
+ setAccountToDelete(account);
875
+ setDeleteDialogOpen(true);
876
+ }}
877
+ >
878
+ <Trash2 className="mr-2 h-4 w-4" />
879
+ {t('delete')}
880
+ </DropdownMenuItem>
881
+ </DropdownMenuContent>
882
+ </DropdownMenu>
883
+ </div>
829
884
 
830
- <div className="border-t pt-2 text-xs text-muted-foreground">
831
- <div className="flex items-center justify-between">
832
- <span>
833
- {t('owner')}:{' '}
834
- {account.owner_user?.name || t('unassigned')}
835
- </span>
836
- </div>
885
+ <div className="grid gap-3 sm:grid-cols-2">
886
+ <AccountInfoTile
887
+ icon={<Mail className="h-3 w-3" />}
888
+ label={t('tileEmail')}
889
+ value={account.email || '-'}
890
+ />
891
+ <AccountInfoTile
892
+ icon={<Phone className="h-3 w-3" />}
893
+ label={t('tilePhone')}
894
+ value={account.phone || '-'}
895
+ />
896
+ <AccountInfoTile
897
+ icon={<Globe className="h-3 w-3" />}
898
+ label={t('tileWebsite')}
899
+ value={account.website || '-'}
900
+ />
901
+ <AccountInfoTile
902
+ icon={<Building2 className="h-3 w-3" />}
903
+ label={t('tileIndustry')}
904
+ value={account.industry || '-'}
905
+ />
906
+ </div>
907
+
908
+ <div className="border-t pt-2 text-xs text-muted-foreground">
909
+ <div className="flex items-center justify-between">
910
+ <span>
911
+ {t('owner')}:{' '}
912
+ {account.owner_user?.name || t('unassigned')}
913
+ </span>
837
914
  </div>
838
- </CardContent>
839
- </Card>
840
- );
841
- })}
915
+ </div>
916
+ </CardContent>
917
+ </Card>
918
+ ))}
842
919
  </div>
843
920
  )}
844
921
 
845
922
  <PaginationFooter
846
923
  currentPage={page}
847
924
  pageSize={pageSize}
848
- totalItems={filteredAccounts.length}
925
+ totalItems={paginate.total}
849
926
  onPageChange={setPage}
850
927
  onPageSizeChange={(nextPageSize) => {
851
928
  setPageSize(nextPageSize);
@@ -859,8 +936,9 @@ export default function AccountsPage() {
859
936
  open={formSheetOpen}
860
937
  onOpenChange={setFormSheetOpen}
861
938
  account={accountToEdit}
862
- owners={crmOwners}
939
+ owners={owners}
863
940
  onSubmit={handleFormSubmit}
941
+ isLoading={isSubmitting}
864
942
  />
865
943
 
866
944
  <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>