@hed-hog/contact 0.0.332 → 0.0.338

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.
@@ -921,7 +921,7 @@ export function PersonPicker({
921
921
  </PopoverContent>
922
922
  </Popover>
923
923
 
924
- {hasSelection && clearable ? (
924
+ {canEditSelection && clearable ? (
925
925
  <Button
926
926
  type="button"
927
927
  variant="outline"
@@ -941,15 +941,15 @@ export function PersonPicker({
941
941
  </Button>
942
942
  ) : null}
943
943
 
944
- {showEditButton ? (
944
+ {showEditButton && canEditSelection ? (
945
945
  <Button
946
946
  type="button"
947
947
  variant="outline"
948
948
  size="icon"
949
949
  className="shrink-0"
950
- disabled={disabled || !canEditSelection}
950
+ disabled={disabled}
951
951
  onClick={() => {
952
- if (!canEditSelection || !onEditSelection) {
952
+ if (!onEditSelection) {
953
953
  return;
954
954
  }
955
955
  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);
@@ -708,8 +708,10 @@ export function PersonFormSheet({
708
708
  allowedTypes,
709
709
  }: PersonFormSheetProps) {
710
710
  const t = useTranslations('contact.ContactPage');
711
- const { request, currentLocaleCode, getSettingValue, user } = useApp();
711
+ const { request, currentLocaleCode, getSettingValue, user, hasRole } =
712
+ useApp();
712
713
  const isEditing = Boolean(person);
714
+ const canEditLinkedUser = hasRole('person-user-linker');
713
715
  const allowCompanyRegistration =
714
716
  getSettingValue('contact-allow-company-registration') !== false;
715
717
  const effectiveAllowedTypes = useMemo<Array<Person['type']>>(() => {
@@ -1146,11 +1148,6 @@ export function PersonFormSheet({
1146
1148
  ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${fileId}`
1147
1149
  : '/placeholder.png';
1148
1150
 
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
1151
  const resolveApiUrl = (url: string) =>
1155
1152
  /^https?:\/\//i.test(url)
1156
1153
  ? url
@@ -2500,10 +2497,17 @@ export function PersonFormSheet({
2500
2497
  </div>
2501
2498
  </CollapsibleTrigger>
2502
2499
 
2503
- <CollapsibleContent className="mt-2">
2500
+ <CollapsibleContent
2501
+ className={cn(
2502
+ 'mt-2',
2503
+ !canEditLinkedUser && 'opacity-60 pointer-events-none'
2504
+ )}
2505
+ >
2504
2506
  <EntityPicker
2507
+ disabled={!canEditLinkedUser}
2505
2508
  value={linkedUserId}
2506
2509
  onChange={(val, opt) => {
2510
+ if (!canEditLinkedUser) return;
2507
2511
  setLinkedUserId(val as number | null);
2508
2512
  setLinkedUserLabel(
2509
2513
  opt
@@ -2513,31 +2517,17 @@ export function PersonFormSheet({
2513
2517
  '')
2514
2518
  : ''
2515
2519
  );
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
2520
  }}
2526
2521
  placeholder={t('selectLinkedUser')}
2527
2522
  entityLabel={t('user')}
2528
- clearable
2523
+ clearable={canEditLinkedUser}
2529
2524
  valueType="number"
2530
2525
  initialSelectedLabel={linkedUserLabel}
2531
2526
  loadOptions={async ({ search }) => {
2532
2527
  const params = new URLSearchParams();
2533
2528
  if (search) params.set('search', search);
2534
2529
  const response = await request<
2535
- Array<{
2536
- id: number;
2537
- name: string;
2538
- email?: string;
2539
- photo_id?: number | null;
2540
- }>
2530
+ Array<{ id: number; name: string; email?: string }>
2541
2531
  >({
2542
2532
  url: `/person/linked-user-options?${params.toString()}`,
2543
2533
  method: 'GET',
@@ -2554,7 +2544,7 @@ export function PersonFormSheet({
2554
2544
  getOptionDescription={(opt) =>
2555
2545
  (opt as { email?: string }).email
2556
2546
  }
2557
- showCreateButton
2547
+ showCreateButton={canEditLinkedUser}
2558
2548
  createTitle={t('createUserTitle')}
2559
2549
  mapSearchToCreateValues={(search) => ({
2560
2550
  name: search,
@@ -63,30 +63,30 @@ type ImportResult = {
63
63
 
64
64
  type ColumnMapping = Record<string, string>;
65
65
 
66
- type CompanyOption = {
67
- id: number;
68
- name: string;
69
- trade_name?: string | null;
70
- };
71
-
72
- type WizardStep = 'upload' | 'preview' | 'mapping' | 'confirm' | 'result';
73
-
74
- function getImportErrorMessage(error: unknown, fallback: string) {
75
- if (typeof error === 'object' && error !== null) {
76
- const response = 'response' in error ? error.response : undefined;
77
- if (typeof response === 'object' && response !== null && 'data' in response) {
78
- const data = response.data;
79
- if (typeof data === 'object' && data !== null && 'message' in data) {
80
- const message = data.message;
81
- if (typeof message === 'string') return message;
82
- }
83
- }
84
- if ('message' in error && typeof error.message === 'string') {
85
- return error.message;
86
- }
87
- }
88
- return fallback;
89
- }
66
+ type CompanyOption = {
67
+ id: number;
68
+ name: string;
69
+ trade_name?: string | null;
70
+ };
71
+
72
+ type WizardStep = 'upload' | 'preview' | 'mapping' | 'confirm' | 'result';
73
+
74
+ function getImportErrorMessage(error: unknown, fallback: string) {
75
+ if (typeof error === 'object' && error !== null) {
76
+ const response = 'response' in error ? error.response : undefined;
77
+ if (typeof response === 'object' && response !== null && 'data' in response) {
78
+ const data = response.data;
79
+ if (typeof data === 'object' && data !== null && 'message' in data) {
80
+ const message = data.message;
81
+ if (typeof message === 'string') return message;
82
+ }
83
+ }
84
+ if ('message' in error && typeof error.message === 'string') {
85
+ return error.message;
86
+ }
87
+ }
88
+ return fallback;
89
+ }
90
90
 
91
91
  // ─── CRM Field Definitions ───────────────────────────────────────────────────
92
92
 
@@ -164,7 +164,7 @@ function StepIndicator({ current }: { current: WizardStep }) {
164
164
  <Icon className="size-3.5" />
165
165
  )}
166
166
  <span className="text-[10px] font-medium leading-tight hidden sm:block">
167
- {t(step.labelKey as never)}
167
+ {t(step.labelKey as never)}
168
168
  </span>
169
169
  </div>
170
170
  );
@@ -436,7 +436,7 @@ function MappingStep({
436
436
  {duplicateFields
437
437
  .map((field) => {
438
438
  const fieldDef = CRM_FIELDS.find((f) => f.value === field);
439
- const label = fieldDef ? t(fieldDef.labelKey as never) : field;
439
+ const label = fieldDef ? t(fieldDef.labelKey as never) : field;
440
440
  return t('importMappingDuplicateWarning', { field: label });
441
441
  })
442
442
  .join(' ')}
@@ -498,7 +498,7 @@ function MappingStep({
498
498
  value={field.value}
499
499
  className="text-xs"
500
500
  >
501
- {t(field.labelKey as never)}
501
+ {t(field.labelKey as never)}
502
502
  </SelectItem>
503
503
  ))}
504
504
  </SelectContent>
@@ -581,7 +581,7 @@ function ConfirmStep({
581
581
  variant="outline"
582
582
  className="text-[10px] border-primary/30 bg-primary/5 text-primary px-1.5 py-0"
583
583
  >
584
- {fieldDef ? t(fieldDef.labelKey as never) : field}
584
+ {fieldDef ? t(fieldDef.labelKey as never) : field}
585
585
  </Badge>
586
586
  </div>
587
587
  );
@@ -605,11 +605,11 @@ function ConfirmStep({
605
605
  onChange={(val) => {
606
606
  onCompanyChange(val ? Number(val) : null);
607
607
  }}
608
- getOptionValue={(opt) => (opt as CompanyOption).id}
609
- getOptionLabel={(opt) => (opt as CompanyOption).name ?? ''}
610
- getOptionDescription={(opt) =>
611
- (opt as CompanyOption).trade_name ?? undefined
612
- }
608
+ getOptionValue={(opt) => (opt as CompanyOption).id}
609
+ getOptionLabel={(opt) => (opt as CompanyOption).name ?? ''}
610
+ getOptionDescription={(opt) =>
611
+ (opt as CompanyOption).trade_name ?? undefined
612
+ }
613
613
  loadOptions={async ({ page, pageSize, search }) => {
614
614
  const params = new URLSearchParams({
615
615
  page: String(page),
@@ -816,29 +816,29 @@ function ResultStep({
816
816
 
817
817
  // ─── Main Sheet ──────────────────────────────────────────────────────────────
818
818
 
819
- export type PersonImportSheetProps = {
820
- open: boolean;
821
- onOpenChange: (open: boolean) => void;
822
- onSuccess: (result?: ImportResult) => void;
823
- initialCompanyId?: number | null;
824
- previewUrl?: string;
825
- importUrl?: string;
826
- title?: string;
827
- description?: string;
828
- nameRequired?: boolean;
829
- };
830
-
831
- export function PersonImportSheet({
832
- open,
833
- onOpenChange,
834
- onSuccess,
835
- initialCompanyId = null,
836
- previewUrl = '/person/import/preview',
837
- importUrl = '/person/import',
838
- title,
839
- description,
840
- nameRequired = true,
841
- }: PersonImportSheetProps) {
819
+ export type PersonImportSheetProps = {
820
+ open: boolean;
821
+ onOpenChange: (open: boolean) => void;
822
+ onSuccess: (result?: ImportResult) => void;
823
+ initialCompanyId?: number | null;
824
+ previewUrl?: string;
825
+ importUrl?: string;
826
+ title?: string;
827
+ description?: string;
828
+ nameRequired?: boolean;
829
+ };
830
+
831
+ export function PersonImportSheet({
832
+ open,
833
+ onOpenChange,
834
+ onSuccess,
835
+ initialCompanyId = null,
836
+ previewUrl = '/person/import/preview',
837
+ importUrl = '/person/import',
838
+ title,
839
+ description,
840
+ nameRequired = true,
841
+ }: PersonImportSheetProps) {
842
842
  const t = useTranslations('contact.ContactPage');
843
843
  const { request } = useApp();
844
844
 
@@ -886,8 +886,8 @@ export function PersonImportSheet({
886
886
  }
887
887
  onOpenChange(nextOpen);
888
888
  },
889
- [initialCompanyId, onOpenChange]
890
- );
889
+ [initialCompanyId, onOpenChange]
890
+ );
891
891
 
892
892
  // ── Auto-initialise mapping from columns ──
893
893
  const initMapping = useCallback((columns: string[]) => {
@@ -899,16 +899,16 @@ export function PersonImportSheet({
899
899
  }, []);
900
900
 
901
901
  // ── Navigation ──
902
- const canGoNext = (): boolean => {
903
- if (step === 'upload') return !!file;
904
- if (step === 'preview') return !!preview && !previewError;
905
- if (step === 'mapping') {
906
- const hasName = Object.values(mapping).includes('name');
907
- const hasEmail = Object.values(mapping).includes('email');
908
- return nameRequired ? hasName : hasEmail;
909
- }
910
- if (step === 'confirm') return true;
911
- return false;
902
+ const canGoNext = (): boolean => {
903
+ if (step === 'upload') return !!file;
904
+ if (step === 'preview') return !!preview && !previewError;
905
+ if (step === 'mapping') {
906
+ const hasName = Object.values(mapping).includes('name');
907
+ const hasEmail = Object.values(mapping).includes('email');
908
+ return nameRequired ? hasName : hasEmail;
909
+ }
910
+ if (step === 'confirm') return true;
911
+ return false;
912
912
  };
913
913
 
914
914
  const handleNext = async () => {
@@ -926,19 +926,19 @@ export function PersonImportSheet({
926
926
  await fetchPreview();
927
927
  } else if (step === 'preview') {
928
928
  setStep('mapping');
929
- } else if (step === 'mapping') {
930
- const hasName = Object.values(mapping).includes('name');
931
- const hasEmail = Object.values(mapping).includes('email');
932
- if (nameRequired && !hasName) {
933
- setMappingError(t('importMappingNameRequired'));
934
- return;
935
- }
936
- if (!nameRequired && !hasEmail) {
937
- setMappingError('Mapeie uma coluna para Email.');
938
- return;
939
- }
940
- setMappingError(null);
941
- setStep('confirm');
929
+ } else if (step === 'mapping') {
930
+ const hasName = Object.values(mapping).includes('name');
931
+ const hasEmail = Object.values(mapping).includes('email');
932
+ if (nameRequired && !hasName) {
933
+ setMappingError(t('importMappingNameRequired'));
934
+ return;
935
+ }
936
+ if (!nameRequired && !hasEmail) {
937
+ setMappingError('Mapeie uma coluna para Email.');
938
+ return;
939
+ }
940
+ setMappingError(null);
941
+ setStep('confirm');
942
942
  } else if (step === 'confirm') {
943
943
  await runImport();
944
944
  }
@@ -961,17 +961,17 @@ export function PersonImportSheet({
961
961
  const formData = new FormData();
962
962
  formData.append('file', file);
963
963
 
964
- const res = await request<ImportPreview>({
965
- url: previewUrl,
966
- method: 'POST',
967
- data: formData,
964
+ const res = await request<ImportPreview>({
965
+ url: previewUrl,
966
+ method: 'POST',
967
+ data: formData,
968
968
  headers: { 'Content-Type': 'multipart/form-data' },
969
969
  });
970
970
 
971
971
  setPreview(res.data);
972
972
  initMapping(res.data.columns);
973
- } catch (err: unknown) {
974
- setPreviewError(getImportErrorMessage(err, t('importErrorGeneric')));
973
+ } catch (err: unknown) {
974
+ setPreviewError(getImportErrorMessage(err, t('importErrorGeneric')));
975
975
  } finally {
976
976
  setPreviewLoading(false);
977
977
  }
@@ -990,17 +990,17 @@ export function PersonImportSheet({
990
990
  formData.append('mapping', JSON.stringify(mapping));
991
991
  if (companyId) formData.append('company_id', String(companyId));
992
992
 
993
- const res = await request<ImportResult>({
994
- url: importUrl,
995
- method: 'POST',
996
- data: formData,
993
+ const res = await request<ImportResult>({
994
+ url: importUrl,
995
+ method: 'POST',
996
+ data: formData,
997
997
  headers: { 'Content-Type': 'multipart/form-data' },
998
998
  });
999
-
1000
- setResult(res.data);
1001
- onSuccess(res.data);
1002
- } catch (err: unknown) {
1003
- setImportError(getImportErrorMessage(err, t('importErrorGeneric')));
999
+
1000
+ setResult(res.data);
1001
+ onSuccess(res.data);
1002
+ } catch (err: unknown) {
1003
+ setImportError(getImportErrorMessage(err, t('importErrorGeneric')));
1004
1004
  } finally {
1005
1005
  setImportLoading(false);
1006
1006
  }
@@ -1019,12 +1019,12 @@ export function PersonImportSheet({
1019
1019
  <Upload className="h-4 w-4 text-primary" />
1020
1020
  </div>
1021
1021
  <div>
1022
- <SheetTitle className="text-base">
1023
- {title ?? t('importSheetTitle')}
1024
- </SheetTitle>
1025
- <SheetDescription className="text-xs">
1026
- {description ?? t('importSheetDescription')}
1027
- </SheetDescription>
1022
+ <SheetTitle className="text-base">
1023
+ {title ?? t('importSheetTitle')}
1024
+ </SheetTitle>
1025
+ <SheetDescription className="text-xs">
1026
+ {description ?? t('importSheetDescription')}
1027
+ </SheetDescription>
1028
1028
  </div>
1029
1029
  </div>
1030
1030
  </SheetHeader>
@@ -33,6 +33,7 @@ import {
33
33
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
34
34
  import { formatDate } from '@/lib/format-date';
35
35
  import { cn } from '@/lib/utils';
36
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
36
37
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
37
38
  import {
38
39
  type ColumnDef,
@@ -328,7 +329,11 @@ export default function PeoplePage() {
328
329
 
329
330
  const [sorting, setSorting] = useState<SortingState>([]);
330
331
  const [page, setPage] = useState(1);
331
- const [pageSize, setPageSize] = useState(12);
332
+ const [pageSize, setPageSize] = usePersistedPageSize({
333
+ storageKey: 'pagination:global:pageSize',
334
+ defaultValue: 12,
335
+ allowedValues: [12, 20, 30, 40, 50],
336
+ });
332
337
  const [searchInput, setSearchInput] = useState('');
333
338
  const [debouncedSearch, setDebouncedSearch] = useState('');
334
339
  const [typeFilter, setTypeFilter] = useState('all');