@hed-hog/contact 0.0.333 → 0.0.347

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.
@@ -20,4 +20,12 @@
20
20
  pt: Aprovador de Propostas
21
21
  description:
22
22
  en: User authorized to approve proposals.
23
- pt: Usuario autorizado a aprovar propostas.
23
+ pt: Usuario autorizado a aprovar propostas.
24
+
25
+ - slug: person-user-linker
26
+ name:
27
+ en: Person User Linker
28
+ pt: Vinculador de Pessoa a Usuario
29
+ description:
30
+ en: User allowed to relate a person to a system user.
31
+ pt: Usuario permitido para relacionar uma pessoa com um usuario do sistema.
@@ -0,0 +1,263 @@
1
+ resource: person
2
+ base_url: /person
3
+ roles: [admin, admin-contact]
4
+
5
+ endpoints:
6
+ - method: GET
7
+ path: /
8
+ description: List persons with pagination and filters
9
+ pagination: true
10
+ response:
11
+ schema: person
12
+ paginated: true
13
+
14
+ - method: GET
15
+ path: /stats
16
+ description: Get aggregated person statistics
17
+
18
+ - method: GET
19
+ path: /dashboard
20
+ description: Get dashboard data (charts, KPIs)
21
+ locale: true
22
+ query:
23
+ period: string?
24
+
25
+ - method: GET
26
+ path: /reports
27
+ description: Get report data
28
+ locale: true
29
+ query:
30
+ from: date?
31
+ to: date?
32
+
33
+ - method: GET
34
+ path: /owner-options
35
+ description: List users available as owner options (search-able)
36
+ query:
37
+ search: string?
38
+
39
+ - method: GET
40
+ path: /linked-user-options
41
+ description: List users available to be linked to a person
42
+ query:
43
+ search: string?
44
+
45
+ - method: GET
46
+ path: /duplicates
47
+ description: Check for duplicate persons by name or document
48
+ query:
49
+ name: string?
50
+ document: string?
51
+
52
+ - method: GET
53
+ path: /accounts
54
+ description: List company/person accounts with pagination
55
+ pagination: true
56
+ query:
57
+ type: enum[company,individual]?
58
+
59
+ - method: GET
60
+ path: /accounts/stats
61
+ description: Get account statistics
62
+
63
+ - method: GET
64
+ path: /followups
65
+ description: List followups with pagination and filters
66
+ pagination: true
67
+ query:
68
+ status: string?
69
+ assigned_to: int?
70
+
71
+ - method: GET
72
+ path: /followups/stats
73
+ description: Get followup statistics
74
+ query:
75
+ period: string?
76
+
77
+ - method: GET
78
+ path: /activities
79
+ description: List activities with pagination and filters
80
+ pagination: true
81
+ query:
82
+ type: string?
83
+ person_id: int?
84
+
85
+ - method: GET
86
+ path: /activities/stats
87
+ description: Get activity statistics
88
+
89
+ - method: GET
90
+ path: /activities/:id
91
+ description: Get a single activity detail
92
+ locale: true
93
+ params:
94
+ id: int
95
+
96
+ - method: GET
97
+ path: /:id
98
+ description: Get a single person with full details
99
+ locale: true
100
+ params:
101
+ id: int
102
+
103
+ - method: GET
104
+ path: /avatar/:id
105
+ description: Stream person avatar image (public)
106
+ auth: public
107
+ params:
108
+ id: int
109
+ locale: true
110
+
111
+ - method: POST
112
+ path: /
113
+ description: Create a new person (individual or company)
114
+ locale: true
115
+ body:
116
+ name: string
117
+ type: enum[individual,company]
118
+ status: enum[active,inactive]
119
+ avatar_id: int?
120
+ birth_date: date?
121
+ gender: enum[male,female,other]?
122
+ job_title: string?
123
+ trade_name: string?
124
+ foundation_date: date?
125
+ legal_nature: string?
126
+ notes: string?
127
+ employer_company_id: int?
128
+ owner_user_id: int?
129
+ user_id: int?
130
+ source: enum[referral,website,social,inbound,outbound,other]?
131
+ lifecycle_stage: enum[new,contacted,qualified,proposal,negotiation,customer,lost]?
132
+ next_action_at: datetime?
133
+ score: number?
134
+ deal_value: number?
135
+ tags: array<string>?
136
+
137
+ - method: POST
138
+ path: /accounts
139
+ description: Create a new account (company linked to persons)
140
+ locale: true
141
+ body:
142
+ name: string
143
+ document: string?
144
+ email: string?
145
+ phone: string?
146
+
147
+ - method: POST
148
+ path: /merge
149
+ description: Merge two person records into one
150
+ locale: true
151
+ body:
152
+ source_id: int
153
+ target_id: int
154
+
155
+ - method: POST
156
+ path: /import/preview
157
+ description: Preview CSV import before applying
158
+ multipart: true
159
+
160
+ - method: POST
161
+ path: /import
162
+ description: Import persons from CSV file
163
+ locale: true
164
+ multipart: true
165
+
166
+ - method: POST
167
+ path: /:id/interaction
168
+ description: Add an interaction record to a person
169
+ locale: true
170
+ params:
171
+ id: int
172
+ body:
173
+ type: string
174
+ content: string
175
+ occurred_at: datetime?
176
+
177
+ - method: POST
178
+ path: /:id/followup
179
+ description: Schedule a followup for a person
180
+ locale: true
181
+ params:
182
+ id: int
183
+ body:
184
+ title: string
185
+ due_at: datetime
186
+ notes: string?
187
+ assigned_to_user_id: int?
188
+
189
+ - method: POST
190
+ path: /:id/lifecycle-stage
191
+ description: Update lifecycle stage of a person
192
+ locale: true
193
+ params:
194
+ id: int
195
+ body:
196
+ stage: enum[new,contacted,qualified,proposal,negotiation,customer,lost]
197
+
198
+ - method: POST
199
+ path: /activities/:id/complete
200
+ description: Mark an activity as completed
201
+ locale: true
202
+ params:
203
+ id: int
204
+
205
+ - method: PATCH
206
+ path: /:id
207
+ description: Update a person record
208
+ locale: true
209
+ params:
210
+ id: int
211
+ body:
212
+ name: string?
213
+ type: enum[individual,company]?
214
+ status: enum[active,inactive]?
215
+ avatar_id: int?
216
+ birth_date: date?
217
+ gender: enum[male,female,other]?
218
+ job_title: string?
219
+ trade_name: string?
220
+ foundation_date: date?
221
+ legal_nature: string?
222
+ notes: string?
223
+ employer_company_id: int?
224
+ owner_user_id: int?
225
+ user_id: int?
226
+ source: enum[referral,website,social,inbound,outbound,other]?
227
+ lifecycle_stage: enum[new,contacted,qualified,proposal,negotiation,customer,lost]?
228
+ next_action_at: datetime?
229
+ score: number?
230
+ deal_value: number?
231
+ tags: array<string>?
232
+
233
+ - method: PATCH
234
+ path: /accounts/:id
235
+ description: Update an account
236
+ locale: true
237
+ params:
238
+ id: int
239
+ body:
240
+ name: string?
241
+ document: string?
242
+ email: string?
243
+ phone: string?
244
+
245
+ - method: DELETE
246
+ path: /
247
+ description: Delete multiple persons (bulk)
248
+ body:
249
+ ids: array<int>
250
+
251
+ - method: DELETE
252
+ path: /accounts
253
+ description: Delete multiple accounts (bulk)
254
+ locale: true
255
+ body:
256
+ ids: array<int>
257
+
258
+ - method: GET
259
+ path: /:id/interaction
260
+ description: List interactions for a person
261
+ locale: true
262
+ params:
263
+ id: int
@@ -574,6 +574,7 @@ export function PersonPicker({
574
574
  createType = 'individual',
575
575
  lockCreateType = false,
576
576
  initialSelectedLabel = '',
577
+ initialSelectedAvatarId,
577
578
  disabled = false,
578
579
  clearable = true,
579
580
  showEditButton = false,
@@ -590,6 +591,7 @@ export function PersonPicker({
590
591
  createType?: Exclude<PersonTypeFilter, 'all'>;
591
592
  lockCreateType?: boolean;
592
593
  initialSelectedLabel?: string;
594
+ initialSelectedAvatarId?: number | null;
593
595
  disabled?: boolean;
594
596
  clearable?: boolean;
595
597
  showEditButton?: boolean;
@@ -605,7 +607,7 @@ export function PersonPicker({
605
607
  useState(initialSelectedLabel);
606
608
  const [selectedPersonAvatarId, setSelectedPersonAvatarId] = useState<
607
609
  number | null
608
- >(null);
610
+ >(initialSelectedAvatarId ?? null);
609
611
  const commandInputRef = useRef<HTMLInputElement | null>(null);
610
612
  const parentScrollContainerRef = useRef<HTMLElement | null>(null);
611
613
  const parentScrollTopRef = useRef(0);
@@ -921,7 +923,7 @@ export function PersonPicker({
921
923
  </PopoverContent>
922
924
  </Popover>
923
925
 
924
- {hasSelection && clearable ? (
926
+ {canEditSelection && clearable ? (
925
927
  <Button
926
928
  type="button"
927
929
  variant="outline"
@@ -941,15 +943,15 @@ export function PersonPicker({
941
943
  </Button>
942
944
  ) : null}
943
945
 
944
- {showEditButton ? (
946
+ {showEditButton && canEditSelection ? (
945
947
  <Button
946
948
  type="button"
947
949
  variant="outline"
948
950
  size="icon"
949
951
  className="shrink-0"
950
- disabled={disabled || !canEditSelection}
952
+ disabled={disabled}
951
953
  onClick={() => {
952
- if (!canEditSelection || !onEditSelection) {
954
+ if (!onEditSelection) {
953
955
  return;
954
956
  }
955
957
  onEditSelection(selectedPersonId);
@@ -40,6 +40,7 @@ import {
40
40
  } from '@/components/ui/table';
41
41
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
42
42
  import { getFormDraftOwnerKey } from '@/hooks/use-form-draft';
43
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
43
44
  import { formatDate } from '@/lib/format-date';
44
45
  import { cn } from '@/lib/utils';
45
46
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
@@ -163,7 +164,11 @@ export default function AccountsPage() {
163
164
 
164
165
  const [sorting, setSorting] = useState<SortingState>([]);
165
166
  const [page, setPage] = useState(1);
166
- const [pageSize, setPageSize] = useState(12);
167
+ const [pageSize, setPageSize] = usePersistedPageSize({
168
+ storageKey: 'pagination:global:pageSize',
169
+ defaultValue: 12,
170
+ allowedValues: [6, 12, 24, 48],
171
+ });
167
172
  const [searchInput, setSearchInput] = useState('');
168
173
  const [debouncedSearch, setDebouncedSearch] = useState('');
169
174
  const [statusFilter, setStatusFilter] = useState('all');
@@ -22,6 +22,7 @@ import {
22
22
  TableRow,
23
23
  } from '@/components/ui/table';
24
24
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
25
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
25
26
  import { formatDateTime } from '@/lib/format-date';
26
27
  import { cn } from '@/lib/utils';
27
28
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
@@ -95,7 +96,11 @@ export default function CrmActivitiesPage() {
95
96
  const [typeFilter, setTypeFilter] = useState('all');
96
97
  const [priorityFilter, setPriorityFilter] = useState('all');
97
98
  const [page, setPage] = useState(1);
98
- const [pageSize, setPageSize] = useState(12);
99
+ const [pageSize, setPageSize] = usePersistedPageSize({
100
+ storageKey: 'pagination:global:pageSize',
101
+ defaultValue: 12,
102
+ allowedValues: [6, 12, 24, 48],
103
+ });
99
104
  const [viewMode, setViewMode] = useState<ActivityViewMode>('table');
100
105
  const [selectedActivityId, setSelectedActivityId] = useState<number | null>(
101
106
  null
@@ -52,6 +52,7 @@ import {
52
52
  TableRow,
53
53
  } from '@/components/ui/table';
54
54
  import { useFormDraft } from '@/hooks/use-form-draft';
55
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
55
56
  import { formatDate, formatDateTime } from '@/lib/format-date';
56
57
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
57
58
  import { zodResolver } from '@hookform/resolvers/zod';
@@ -126,7 +127,11 @@ export default function ContactTypePage() {
126
127
  useState<ContactType | null>(null);
127
128
 
128
129
  const [page, setPage] = useState(1);
129
- const [pageSize, setPageSize] = useState(12);
130
+ const [pageSize, setPageSize] = usePersistedPageSize({
131
+ storageKey: 'pagination:global:pageSize',
132
+ defaultValue: 10,
133
+ allowedValues: [10, 20, 30, 40, 50],
134
+ });
130
135
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
131
136
  const [deletingId, setDeletingId] = useState<number | null>(null);
132
137
 
@@ -61,6 +61,7 @@ import {
61
61
  } from '@/components/ui/table';
62
62
  import { COUNTRIES } from '@/constants/countries';
63
63
  import { useFormDraft } from '@/hooks/use-form-draft';
64
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
64
65
  import { formatDate, formatDateTime } from '@/lib/format-date';
65
66
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
66
67
  import { zodResolver } from '@hookform/resolvers/zod';
@@ -143,7 +144,11 @@ export default function DocumentTypePage() {
143
144
  useState<DocumentType | null>(null);
144
145
 
145
146
  const [page, setPage] = useState(1);
146
- const [pageSize, setPageSize] = useState(12);
147
+ const [pageSize, setPageSize] = usePersistedPageSize({
148
+ storageKey: 'pagination:global:pageSize',
149
+ defaultValue: 10,
150
+ allowedValues: [10, 20, 30, 40, 50],
151
+ });
147
152
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
148
153
  const [deletingId, setDeletingId] = useState<number | null>(null);
149
154
 
@@ -54,6 +54,7 @@ import {
54
54
  import { Textarea } from '@/components/ui/textarea';
55
55
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
56
56
  import { useFormDraft } from '@/hooks/use-form-draft';
57
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
57
58
  import { formatDateTime } from '@/lib/format-date';
58
59
  import { cn } from '@/lib/utils';
59
60
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
@@ -206,7 +207,11 @@ export default function CrmFollowupsPage() {
206
207
  const [dateFrom, setDateFrom] = useState('');
207
208
  const [dateTo, setDateTo] = useState('');
208
209
  const [page, setPage] = useState(1);
209
- const [pageSize, setPageSize] = useState(12);
210
+ const [pageSize, setPageSize] = usePersistedPageSize({
211
+ storageKey: 'pagination:global:pageSize',
212
+ defaultValue: 12,
213
+ allowedValues: [6, 12, 24, 48],
214
+ });
210
215
  const [viewMode, setViewMode] = useState<FollowupViewMode>('table');
211
216
  const [sheetOpen, setSheetOpen] = useState(false);
212
217
  const [personPickerOpen, setPersonPickerOpen] = useState(false);
@@ -710,6 +710,19 @@ export function PersonFormSheet({
710
710
  const t = useTranslations('contact.ContactPage');
711
711
  const { request, currentLocaleCode, getSettingValue, user } = useApp();
712
712
  const isEditing = Boolean(person);
713
+ const canEditLinkedUser = useMemo(
714
+ () =>
715
+ (user?.role_user ?? []).some((roleUser) => {
716
+ const roleSlug = roleUser.role?.slug;
717
+
718
+ return (
719
+ roleSlug === 'admin' ||
720
+ roleSlug === 'admin-contact' ||
721
+ roleSlug === 'person-user-linker'
722
+ );
723
+ }),
724
+ [user]
725
+ );
713
726
  const allowCompanyRegistration =
714
727
  getSettingValue('contact-allow-company-registration') !== false;
715
728
  const effectiveAllowedTypes = useMemo<Array<Person['type']>>(() => {
@@ -1146,11 +1159,6 @@ export function PersonFormSheet({
1146
1159
  ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${fileId}`
1147
1160
  : '/placeholder.png';
1148
1161
 
1149
- const getUserPhotoUrl = (photoId?: number | null) =>
1150
- typeof photoId === 'number' && photoId > 0
1151
- ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/user/avatar/${photoId}`
1152
- : undefined;
1153
-
1154
1162
  const resolveApiUrl = (url: string) =>
1155
1163
  /^https?:\/\//i.test(url)
1156
1164
  ? url
@@ -2500,10 +2508,17 @@ export function PersonFormSheet({
2500
2508
  </div>
2501
2509
  </CollapsibleTrigger>
2502
2510
 
2503
- <CollapsibleContent className="mt-2">
2511
+ <CollapsibleContent
2512
+ className={cn(
2513
+ 'mt-2',
2514
+ !canEditLinkedUser && 'opacity-60 pointer-events-none'
2515
+ )}
2516
+ >
2504
2517
  <EntityPicker
2518
+ disabled={!canEditLinkedUser}
2505
2519
  value={linkedUserId}
2506
2520
  onChange={(val, opt) => {
2521
+ if (!canEditLinkedUser) return;
2507
2522
  setLinkedUserId(val as number | null);
2508
2523
  setLinkedUserLabel(
2509
2524
  opt
@@ -2513,31 +2528,17 @@ export function PersonFormSheet({
2513
2528
  '')
2514
2529
  : ''
2515
2530
  );
2516
- const userPhotoId = (
2517
- opt as { photo_id?: number | null } | null
2518
- )?.photo_id;
2519
- if (val != null && userPhotoId && !avatarId) {
2520
- setAvatarId(userPhotoId);
2521
- setAvatarPreviewUrl(
2522
- getUserPhotoUrl(userPhotoId) ?? '/placeholder.png'
2523
- );
2524
- }
2525
2531
  }}
2526
2532
  placeholder={t('selectLinkedUser')}
2527
2533
  entityLabel={t('user')}
2528
- clearable
2534
+ clearable={canEditLinkedUser}
2529
2535
  valueType="number"
2530
2536
  initialSelectedLabel={linkedUserLabel}
2531
2537
  loadOptions={async ({ search }) => {
2532
2538
  const params = new URLSearchParams();
2533
2539
  if (search) params.set('search', search);
2534
2540
  const response = await request<
2535
- Array<{
2536
- id: number;
2537
- name: string;
2538
- email?: string;
2539
- photo_id?: number | null;
2540
- }>
2541
+ Array<{ id: number; name: string; email?: string }>
2541
2542
  >({
2542
2543
  url: `/person/linked-user-options?${params.toString()}`,
2543
2544
  method: 'GET',
@@ -2554,7 +2555,7 @@ export function PersonFormSheet({
2554
2555
  getOptionDescription={(opt) =>
2555
2556
  (opt as { email?: string }).email
2556
2557
  }
2557
- showCreateButton
2558
+ showCreateButton={canEditLinkedUser}
2558
2559
  createTitle={t('createUserTitle')}
2559
2560
  mapSearchToCreateValues={(search) => ({
2560
2561
  name: search,