@hed-hog/contact 0.0.278 → 0.0.285

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 (70) hide show
  1. package/README.md +1 -4
  2. package/dist/person/dto/create-followup.dto.d.ts +5 -0
  3. package/dist/person/dto/create-followup.dto.d.ts.map +1 -0
  4. package/dist/person/dto/create-followup.dto.js +31 -0
  5. package/dist/person/dto/create-followup.dto.js.map +1 -0
  6. package/dist/person/dto/create-interaction.dto.d.ts +12 -0
  7. package/dist/person/dto/create-interaction.dto.d.ts.map +1 -0
  8. package/dist/person/dto/create-interaction.dto.js +39 -0
  9. package/dist/person/dto/create-interaction.dto.js.map +1 -0
  10. package/dist/person/dto/create.dto.d.ts +24 -0
  11. package/dist/person/dto/create.dto.d.ts.map +1 -1
  12. package/dist/person/dto/create.dto.js +56 -1
  13. package/dist/person/dto/create.dto.js.map +1 -1
  14. package/dist/person/dto/duplicates-query.dto.d.ts +8 -0
  15. package/dist/person/dto/duplicates-query.dto.d.ts.map +1 -0
  16. package/dist/person/dto/duplicates-query.dto.js +45 -0
  17. package/dist/person/dto/duplicates-query.dto.js.map +1 -0
  18. package/dist/person/dto/merge.dto.d.ts +6 -0
  19. package/dist/person/dto/merge.dto.d.ts.map +1 -0
  20. package/dist/person/dto/merge.dto.js +35 -0
  21. package/dist/person/dto/merge.dto.js.map +1 -0
  22. package/dist/person/dto/update-lifecycle-stage.dto.d.ts +13 -0
  23. package/dist/person/dto/update-lifecycle-stage.dto.d.ts.map +1 -0
  24. package/dist/person/dto/update-lifecycle-stage.dto.js +34 -0
  25. package/dist/person/dto/update-lifecycle-stage.dto.js.map +1 -0
  26. package/dist/person/dto/update.dto.d.ts +8 -1
  27. package/dist/person/dto/update.dto.d.ts.map +1 -1
  28. package/dist/person/dto/update.dto.js +36 -0
  29. package/dist/person/dto/update.dto.js.map +1 -1
  30. package/dist/person/person.controller.d.ts +57 -1
  31. package/dist/person/person.controller.d.ts.map +1 -1
  32. package/dist/person/person.controller.js +85 -3
  33. package/dist/person/person.controller.js.map +1 -1
  34. package/dist/person/person.service.d.ts +79 -0
  35. package/dist/person/person.service.d.ts.map +1 -1
  36. package/dist/person/person.service.js +730 -9
  37. package/dist/person/person.service.js.map +1 -1
  38. package/hedhog/data/route.yaml +18 -0
  39. package/hedhog/frontend/app/_components/crm-coming-soon.tsx.ejs +110 -110
  40. package/hedhog/frontend/app/_components/crm-nav.tsx.ejs +73 -73
  41. package/hedhog/frontend/app/_lib/crm-mocks.ts.ejs +498 -256
  42. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +81 -81
  43. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +477 -0
  44. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +62 -0
  45. package/hedhog/frontend/app/accounts/page.tsx.ejs +886 -15
  46. package/hedhog/frontend/app/activities/page.tsx.ejs +15 -15
  47. package/hedhog/frontend/app/contact-type/page.tsx.ejs +105 -91
  48. package/hedhog/frontend/app/dashboard/page.tsx.ejs +506 -573
  49. package/hedhog/frontend/app/document-type/page.tsx.ejs +105 -91
  50. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +15 -15
  51. package/hedhog/frontend/app/page.tsx.ejs +5 -5
  52. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1440 -1103
  53. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +4 -3
  54. package/hedhog/frontend/app/person/_components/person-types.ts.ejs +14 -0
  55. package/hedhog/frontend/app/person/page.tsx.ejs +108 -190
  56. package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +599 -0
  57. package/hedhog/frontend/app/pipeline/page.tsx.ejs +1074 -299
  58. package/hedhog/frontend/app/reports/page.tsx.ejs +15 -15
  59. package/hedhog/frontend/messages/en.json +107 -0
  60. package/hedhog/frontend/messages/pt.json +106 -0
  61. package/package.json +6 -6
  62. package/src/person/dto/create-followup.dto.ts +15 -0
  63. package/src/person/dto/create-interaction.dto.ts +23 -0
  64. package/src/person/dto/create.dto.ts +50 -0
  65. package/src/person/dto/duplicates-query.dto.ts +34 -0
  66. package/src/person/dto/merge.dto.ts +15 -0
  67. package/src/person/dto/update-lifecycle-stage.dto.ts +19 -0
  68. package/src/person/dto/update.dto.ts +31 -1
  69. package/src/person/person.controller.ts +63 -2
  70. package/src/person/person.service.ts +1096 -7
@@ -1,6 +1,14 @@
1
1
  'use client';
2
2
 
3
3
  import { PersonFieldWithCreate } from '@/app/(app)/(libraries)/contact/person/_components/person-field-with-create';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogContent,
7
+ AlertDialogDescription,
8
+ AlertDialogFooter,
9
+ AlertDialogHeader,
10
+ AlertDialogTitle,
11
+ } from '@/components/ui/alert-dialog';
4
12
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
5
13
  import { Badge } from '@/components/ui/badge';
6
14
  import { Button } from '@/components/ui/button';
@@ -136,6 +144,60 @@ type EditablePersonDocument = Omit<PersonDocument, 'document_type_id'> & {
136
144
  document_type_id: number | null;
137
145
  };
138
146
 
147
+ type DuplicateReason = 'email' | 'phone' | 'document';
148
+
149
+ type DuplicateMatch = {
150
+ id: number;
151
+ name: string;
152
+ reasons: DuplicateReason[];
153
+ };
154
+
155
+ type PersonSubmitPayload = {
156
+ name: string;
157
+ type: 'individual' | 'company';
158
+ status: 'active' | 'inactive';
159
+ avatar_id: number | null;
160
+ birth_date: string | null;
161
+ gender: PersonGender | null;
162
+ job_title: string | null;
163
+ employer_company_id: number | null;
164
+ owner_user_id: number | null;
165
+ source: PersonSource | null;
166
+ lifecycle_stage: PersonLifecycleStage | null;
167
+ next_action_at: string | null;
168
+ trade_name: string | null;
169
+ foundation_date: string | null;
170
+ legal_nature: string | null;
171
+ contacts: Array<{
172
+ id?: number;
173
+ value: string;
174
+ is_primary: boolean;
175
+ contact_type_id: number;
176
+ }>;
177
+ addresses: Array<{
178
+ id?: number;
179
+ line1: string;
180
+ line2: string;
181
+ city: string;
182
+ state: string;
183
+ country_code: string;
184
+ postal_code: string;
185
+ is_primary: boolean;
186
+ address_type: PersonAddress['address_type'];
187
+ }>;
188
+ documents: Array<{
189
+ id?: number;
190
+ value: string;
191
+ document_type_id: number;
192
+ }>;
193
+ };
194
+
195
+ type PendingDuplicateSubmission = {
196
+ payload: PersonSubmitPayload;
197
+ normalizedType: 'individual' | 'company';
198
+ matches: DuplicateMatch[];
199
+ };
200
+
139
201
  function createClientId(prefix: string) {
140
202
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
141
203
  }
@@ -431,6 +493,14 @@ export function PersonFormSheet({
431
493
  useState<string>('/placeholder.png');
432
494
  const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
433
495
  const [avatarUploadProgress, setAvatarUploadProgress] = useState(0);
496
+ const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
497
+ const [pendingDuplicateSubmission, setPendingDuplicateSubmission] =
498
+ useState<PendingDuplicateSubmission | null>(null);
499
+ const [duplicateTargetPersonId, setDuplicateTargetPersonId] = useState<
500
+ number | null
501
+ >(null);
502
+ const [isResolvingDuplicateAction, setIsResolvingDuplicateAction] =
503
+ useState(false);
434
504
  const fileInputRef = useRef<HTMLInputElement>(null);
435
505
  const hasSavedChangesRef = useRef(false);
436
506
  const contactValueRefs = useRef<Record<string, HTMLInputElement | null>>({});
@@ -545,6 +615,10 @@ export function PersonFormSheet({
545
615
  setAddressesOpen(true);
546
616
  setDocumentsOpen(true);
547
617
  setLoadingCEP({});
618
+ setDuplicateDialogOpen(false);
619
+ setPendingDuplicateSubmission(null);
620
+ setDuplicateTargetPersonId(null);
621
+ setIsResolvingDuplicateAction(false);
548
622
  }, [allowCompanyRegistration, open, person, reset, user?.id]);
549
623
 
550
624
  const getPersonInitials = (name: string) =>
@@ -1051,168 +1125,291 @@ export function PersonFormSheet({
1051
1125
  setLoadingCEP((previous) => ({ ...previous, [clientId]: false }));
1052
1126
  };
1053
1127
 
1128
+ const getDuplicateReasonLabel = (reason: DuplicateReason) => {
1129
+ if (reason === 'email') return t('columnEmail');
1130
+ if (reason === 'phone') return t('columnPhone');
1131
+ return t('documentValue');
1132
+ };
1133
+
1134
+ const buildSubmitPayload = (
1135
+ values: PersonFormValues,
1136
+ normalizedType: 'individual' | 'company'
1137
+ ): PersonSubmitPayload => ({
1138
+ name: values.name.trim(),
1139
+ type: normalizedType,
1140
+ status: values.status,
1141
+ avatar_id: avatarId,
1142
+ birth_date: values.birth_date ? values.birth_date.toISOString() : null,
1143
+ gender: values.gender || null,
1144
+ job_title:
1145
+ normalizedType === 'individual' ? values.job_title?.trim() || null : null,
1146
+ employer_company_id:
1147
+ normalizedType === 'individual' && allowCompanyRegistration
1148
+ ? (values.employer_company_id ?? null)
1149
+ : null,
1150
+ owner_user_id: values.owner_user_id ?? null,
1151
+ source: values.source || null,
1152
+ lifecycle_stage: values.lifecycle_stage || 'new',
1153
+ next_action_at: values.next_action_at
1154
+ ? new Date(values.next_action_at).toISOString()
1155
+ : null,
1156
+ trade_name: values.trade_name?.trim() || null,
1157
+ foundation_date: values.foundation_date
1158
+ ? values.foundation_date.toISOString()
1159
+ : null,
1160
+ legal_nature: values.legal_nature?.trim() || null,
1161
+ contacts: contacts
1162
+ .filter(
1163
+ (contact) =>
1164
+ contact.value.trim().length > 0 && !!contact.contact_type_id
1165
+ )
1166
+ .map((contact) => ({
1167
+ id: contact.id,
1168
+ value: contact.value.trim(),
1169
+ is_primary: contact.is_primary,
1170
+ contact_type_id: contact.contact_type_id as number,
1171
+ })),
1172
+ addresses: addresses
1173
+ .filter(
1174
+ (address) =>
1175
+ address.line1.trim().length > 0 ||
1176
+ address.city.trim().length > 0 ||
1177
+ address.state.trim().length > 0 ||
1178
+ (address.postal_code || '').trim().length > 0
1179
+ )
1180
+ .map((address) => ({
1181
+ id: address.id,
1182
+ line1: address.line1.trim(),
1183
+ line2: address.line2?.trim() || '',
1184
+ city: address.city.trim(),
1185
+ state: address.state.trim(),
1186
+ country_code: address.country_code || 'BRA',
1187
+ postal_code: address.postal_code?.trim() || '',
1188
+ is_primary: address.is_primary,
1189
+ address_type: address.address_type,
1190
+ })),
1191
+ documents: documents
1192
+ .filter(
1193
+ (document) =>
1194
+ document.value.trim().length > 0 && !!document.document_type_id
1195
+ )
1196
+ .map((document) => ({
1197
+ id: document.id,
1198
+ value: document.value.trim(),
1199
+ document_type_id: document.document_type_id as number,
1200
+ })),
1201
+ });
1202
+
1203
+ const findDuplicates = async (payload: PersonSubmitPayload) => {
1204
+ const primaryEmail = payload.contacts.find((contact) =>
1205
+ ['EMAIL'].includes(getContactTypeCode(contact.contact_type_id))
1206
+ );
1207
+ const primaryPhone = payload.contacts.find((contact) =>
1208
+ ['PHONE', 'MOBILE', 'WHATSAPP'].includes(
1209
+ getContactTypeCode(contact.contact_type_id)
1210
+ )
1211
+ );
1212
+ const firstDocument = payload.documents[0];
1213
+
1214
+ if (!primaryEmail && !primaryPhone && !firstDocument) {
1215
+ return [] as DuplicateMatch[];
1216
+ }
1217
+
1218
+ const duplicateParams = new URLSearchParams();
1219
+ if (person?.id) duplicateParams.set('person_id', String(person.id));
1220
+ if (primaryEmail?.value) duplicateParams.set('email', primaryEmail.value);
1221
+ if (primaryPhone?.value) duplicateParams.set('phone', primaryPhone.value);
1222
+ if (firstDocument?.value && firstDocument?.document_type_id) {
1223
+ duplicateParams.set('document_value', firstDocument.value);
1224
+ duplicateParams.set(
1225
+ 'document_type_id',
1226
+ String(firstDocument.document_type_id)
1227
+ );
1228
+ }
1229
+
1230
+ if (duplicateParams.size === 0) {
1231
+ return [] as DuplicateMatch[];
1232
+ }
1233
+
1234
+ const duplicateResponse = await request<{
1235
+ hasDuplicates: boolean;
1236
+ matches: DuplicateMatch[];
1237
+ }>({
1238
+ url: `/person/duplicates?${duplicateParams.toString()}`,
1239
+ method: 'GET',
1240
+ });
1241
+
1242
+ if (!duplicateResponse.data.hasDuplicates) {
1243
+ return [] as DuplicateMatch[];
1244
+ }
1245
+
1246
+ return duplicateResponse.data.matches || [];
1247
+ };
1248
+
1249
+ const persistPersonPayload = async (
1250
+ payload: PersonSubmitPayload,
1251
+ normalizedType: 'individual' | 'company',
1252
+ options?: { showSuccessToast?: boolean }
1253
+ ) => {
1254
+ const showSuccessToast = options?.showSuccessToast !== false;
1255
+
1256
+ if (person) {
1257
+ await request({
1258
+ url: `/person/${person.id}`,
1259
+ method: 'PATCH',
1260
+ data: payload,
1261
+ });
1262
+
1263
+ setPersistedAvatarId(avatarId);
1264
+
1265
+ if (showSuccessToast) {
1266
+ toast.success(t('updateSuccess'));
1267
+ }
1268
+
1269
+ return person.id;
1270
+ }
1271
+
1272
+ const createResponse = await request<CreatePersonPayload>({
1273
+ url: '/person',
1274
+ method: 'POST',
1275
+ data: {
1276
+ name: payload.name,
1277
+ type: normalizedType,
1278
+ status: payload.status,
1279
+ avatar_id: payload.avatar_id,
1280
+ birth_date: payload.birth_date,
1281
+ gender: payload.gender,
1282
+ job_title: payload.job_title,
1283
+ employer_company_id: payload.employer_company_id,
1284
+ trade_name: payload.trade_name,
1285
+ foundation_date: payload.foundation_date,
1286
+ legal_nature: payload.legal_nature,
1287
+ },
1288
+ });
1289
+
1290
+ const personId = Number(
1291
+ createResponse?.data?.id ?? createResponse?.data?.data?.id
1292
+ );
1293
+
1294
+ if (!personId) {
1295
+ throw new Error('Could not resolve created person id');
1296
+ }
1297
+
1298
+ setPersistedAvatarId(avatarId);
1299
+ await request({
1300
+ url: `/person/${personId}`,
1301
+ method: 'PATCH',
1302
+ data: payload,
1303
+ });
1304
+
1305
+ if (showSuccessToast) {
1306
+ toast.success(t('createSuccess'));
1307
+ }
1308
+
1309
+ return personId;
1310
+ };
1311
+
1312
+ const finalizeSuccess = () => {
1313
+ hasSavedChangesRef.current = true;
1314
+ setPendingDuplicateSubmission(null);
1315
+ setDuplicateTargetPersonId(null);
1316
+ setDuplicateDialogOpen(false);
1317
+ handleSheetOpenChange(false);
1318
+ onSuccess();
1319
+ };
1320
+
1321
+ const handleContinueWithDuplicate = async () => {
1322
+ if (!pendingDuplicateSubmission) return;
1323
+
1324
+ try {
1325
+ setIsResolvingDuplicateAction(true);
1326
+ await persistPersonPayload(
1327
+ pendingDuplicateSubmission.payload,
1328
+ pendingDuplicateSubmission.normalizedType
1329
+ );
1330
+ finalizeSuccess();
1331
+ } catch (error: unknown) {
1332
+ const message = error instanceof Error ? error.message : null;
1333
+ toast.error(message || (isEditing ? t('updateError') : t('createError')));
1334
+ } finally {
1335
+ setIsResolvingDuplicateAction(false);
1336
+ }
1337
+ };
1338
+
1339
+ const handleMergeWithDuplicate = async () => {
1340
+ if (!pendingDuplicateSubmission || !duplicateTargetPersonId) {
1341
+ toast.error(t('duplicateSelectTarget'));
1342
+ return;
1343
+ }
1344
+
1345
+ try {
1346
+ setIsResolvingDuplicateAction(true);
1347
+
1348
+ const sourcePersonId = await persistPersonPayload(
1349
+ pendingDuplicateSubmission.payload,
1350
+ pendingDuplicateSubmission.normalizedType,
1351
+ { showSuccessToast: false }
1352
+ );
1353
+
1354
+ await request({
1355
+ url: '/person/merge',
1356
+ method: 'POST',
1357
+ data: {
1358
+ source_person_id: sourcePersonId,
1359
+ target_person_id: duplicateTargetPersonId,
1360
+ strategy: 'contact_only',
1361
+ },
1362
+ });
1363
+
1364
+ const target = pendingDuplicateSubmission.matches.find(
1365
+ (item) => item.id === duplicateTargetPersonId
1366
+ );
1367
+
1368
+ toast.success(
1369
+ t('duplicateMergeSuccess', {
1370
+ name: target?.name || '#',
1371
+ })
1372
+ );
1373
+
1374
+ finalizeSuccess();
1375
+ } catch (error: unknown) {
1376
+ const message = error instanceof Error ? error.message : null;
1377
+ toast.error(message || t('duplicateMergeError'));
1378
+ } finally {
1379
+ setIsResolvingDuplicateAction(false);
1380
+ }
1381
+ };
1382
+
1054
1383
  const handleFormSubmit = async (values: PersonFormValues) => {
1055
1384
  try {
1056
1385
  setIsSubmitting(true);
1386
+
1057
1387
  const normalizedType = allowCompanyRegistration
1058
1388
  ? values.type
1059
1389
  : 'individual';
1060
1390
 
1061
- const payload = {
1062
- name: values.name.trim(),
1063
- type: normalizedType,
1064
- status: values.status,
1065
- avatar_id: avatarId,
1066
- birth_date: values.birth_date ? values.birth_date.toISOString() : null,
1067
- gender: values.gender || null,
1068
- job_title:
1069
- normalizedType === 'individual'
1070
- ? values.job_title?.trim() || null
1071
- : null,
1072
- employer_company_id:
1073
- normalizedType === 'individual' && allowCompanyRegistration
1074
- ? (values.employer_company_id ?? null)
1075
- : null,
1076
- trade_name: values.trade_name?.trim() || null,
1077
- foundation_date: values.foundation_date
1078
- ? values.foundation_date.toISOString()
1079
- : null,
1080
- legal_nature: values.legal_nature?.trim() || null,
1081
- contacts: contacts
1082
- .filter(
1083
- (contact) =>
1084
- contact.value.trim().length > 0 && !!contact.contact_type_id
1085
- )
1086
- .map((contact) => ({
1087
- id: contact.id,
1088
- value: contact.value.trim(),
1089
- is_primary: contact.is_primary,
1090
- contact_type_id: contact.contact_type_id as number,
1091
- })),
1092
- addresses: addresses
1093
- .filter(
1094
- (address) =>
1095
- address.line1.trim().length > 0 ||
1096
- address.city.trim().length > 0 ||
1097
- address.state.trim().length > 0 ||
1098
- (address.postal_code || '').trim().length > 0
1099
- )
1100
- .map((address) => ({
1101
- id: address.id,
1102
- line1: address.line1.trim(),
1103
- line2: address.line2?.trim() || '',
1104
- city: address.city.trim(),
1105
- state: address.state.trim(),
1106
- country_code: address.country_code || 'BRA',
1107
- postal_code: address.postal_code?.trim() || '',
1108
- is_primary: address.is_primary,
1109
- address_type: address.address_type,
1110
- })),
1111
- documents: documents
1112
- .filter(
1113
- (document) =>
1114
- document.value.trim().length > 0 && !!document.document_type_id
1115
- )
1116
- .map((document) => ({
1117
- id: document.id,
1118
- value: document.value.trim(),
1119
- document_type_id: document.document_type_id as number,
1120
- })),
1121
- };
1122
-
1123
- const primaryEmail = payload.contacts.find((contact) =>
1124
- ['EMAIL'].includes(getContactTypeCode(contact.contact_type_id))
1125
- );
1126
- const primaryPhone = payload.contacts.find((contact) =>
1127
- ['PHONE', 'MOBILE', 'WHATSAPP'].includes(
1128
- getContactTypeCode(contact.contact_type_id)
1129
- )
1130
- );
1131
- const firstDocument = payload.documents[0];
1132
-
1133
- if (primaryEmail || primaryPhone || firstDocument) {
1134
- const duplicateParams = new URLSearchParams();
1135
- if (person?.id) duplicateParams.set('person_id', String(person.id));
1136
- if (primaryEmail?.value)
1137
- duplicateParams.set('email', primaryEmail.value);
1138
- if (primaryPhone?.value)
1139
- duplicateParams.set('phone', primaryPhone.value);
1140
- if (firstDocument?.value && firstDocument?.document_type_id) {
1141
- duplicateParams.set('document_value', firstDocument.value);
1142
- duplicateParams.set(
1143
- 'document_type_id',
1144
- String(firstDocument.document_type_id)
1145
- );
1146
- }
1147
-
1148
- if (duplicateParams.size > 0) {
1149
- const duplicateResponse = await request<{
1150
- hasDuplicates: boolean;
1151
- matches: Array<{ id: number; name: string; reasons: string[] }>;
1152
- }>({
1153
- url: `/person/duplicates?${duplicateParams.toString()}`,
1154
- method: 'GET',
1155
- });
1156
-
1157
- if (duplicateResponse.data.hasDuplicates) {
1158
- toast.warning(
1159
- t('duplicateWarning', {
1160
- names: duplicateResponse.data.matches
1161
- .map((item) => item.name)
1162
- .join(', '),
1163
- })
1164
- );
1165
- }
1166
- }
1167
- }
1391
+ const payload = buildSubmitPayload(values, normalizedType);
1392
+ const matches = await findDuplicates(payload);
1168
1393
 
1169
- if (person) {
1170
- await request({
1171
- url: `/person/${person.id}`,
1172
- method: 'PATCH',
1173
- data: payload,
1174
- });
1175
- setPersistedAvatarId(avatarId);
1176
- toast.success(t('updateSuccess'));
1177
- } else {
1178
- const createResponse = await request<CreatePersonPayload>({
1179
- url: '/person',
1180
- method: 'POST',
1181
- data: {
1182
- name: payload.name,
1183
- type: normalizedType,
1184
- status: payload.status,
1185
- avatar_id: payload.avatar_id,
1186
- birth_date: payload.birth_date,
1187
- gender: payload.gender,
1188
- job_title: payload.job_title,
1189
- employer_company_id: payload.employer_company_id,
1190
- trade_name: payload.trade_name,
1191
- foundation_date: payload.foundation_date,
1192
- legal_nature: payload.legal_nature,
1193
- },
1394
+ if (matches.length > 0) {
1395
+ setPendingDuplicateSubmission({
1396
+ payload,
1397
+ normalizedType,
1398
+ matches,
1194
1399
  });
1400
+ setDuplicateTargetPersonId(matches[0]?.id || null);
1401
+ setDuplicateDialogOpen(true);
1195
1402
 
1196
- const personId = Number(
1197
- createResponse?.data?.id ?? createResponse?.data?.data?.id
1403
+ toast.warning(
1404
+ t('duplicateWarning', {
1405
+ names: matches.map((item) => item.name).join(', '),
1406
+ })
1198
1407
  );
1199
-
1200
- if (!personId) {
1201
- throw new Error('Could not resolve created person id');
1202
- }
1203
-
1204
- setPersistedAvatarId(avatarId);
1205
- await request({
1206
- url: `/person/${personId}`,
1207
- method: 'PATCH',
1208
- data: payload,
1209
- });
1210
- toast.success(t('createSuccess'));
1408
+ return;
1211
1409
  }
1212
1410
 
1213
- hasSavedChangesRef.current = true;
1214
- handleSheetOpenChange(false);
1215
- onSuccess();
1411
+ await persistPersonPayload(payload, normalizedType);
1412
+ finalizeSuccess();
1216
1413
  } catch (error: unknown) {
1217
1414
  const message = error instanceof Error ? error.message : null;
1218
1415
  toast.error(message || (isEditing ? t('updateError') : t('createError')));
@@ -1222,1056 +1419,1196 @@ export function PersonFormSheet({
1222
1419
  };
1223
1420
 
1224
1421
  return (
1225
- <Sheet open={open} onOpenChange={handleSheetOpenChange}>
1226
- <SheetContent className="flex h-full w-full max-w-full flex-col overflow-hidden p-0 lg:max-w-4xl xl:max-w-5xl">
1227
- <SheetHeader className="shrink-0 border-b p-4">
1228
- <div className="flex items-center gap-3">
1229
- <div
1230
- className={cn(
1231
- 'flex h-10 w-10 items-center justify-center rounded-full',
1232
- watchType === 'individual'
1233
- ? 'bg-blue-500/10'
1234
- : 'bg-amber-500/10'
1235
- )}
1236
- >
1237
- {watchType === 'individual' ? (
1238
- <User className="h-5 w-5 text-blue-600" />
1239
- ) : (
1240
- <Building2 className="h-5 w-5 text-amber-600" />
1241
- )}
1242
- </div>
1243
- <div>
1244
- <SheetTitle>
1245
- {isEditing ? t('sheetEditTitle') : t('sheetCreateTitle')}
1246
- </SheetTitle>
1247
- <SheetDescription>
1248
- {isEditing
1249
- ? t('sheetEditDescription')
1250
- : allowCompanyRegistration
1251
- ? t('sheetCreateDescription')
1252
- : t('sheetCreateDescriptionIndividualOnly')}
1253
- </SheetDescription>
1422
+ <>
1423
+ <Sheet open={open} onOpenChange={handleSheetOpenChange}>
1424
+ <SheetContent className="flex h-full w-full max-w-full flex-col overflow-hidden p-0 lg:max-w-4xl xl:max-w-5xl">
1425
+ <SheetHeader className="shrink-0 border-b p-4">
1426
+ <div className="flex items-center gap-3">
1427
+ <div
1428
+ className={cn(
1429
+ 'flex h-10 w-10 items-center justify-center rounded-full',
1430
+ watchType === 'individual'
1431
+ ? 'bg-blue-500/10'
1432
+ : 'bg-amber-500/10'
1433
+ )}
1434
+ >
1435
+ {watchType === 'individual' ? (
1436
+ <User className="h-5 w-5 text-blue-600" />
1437
+ ) : (
1438
+ <Building2 className="h-5 w-5 text-amber-600" />
1439
+ )}
1440
+ </div>
1441
+ <div>
1442
+ <SheetTitle>
1443
+ {isEditing ? t('sheetEditTitle') : t('sheetCreateTitle')}
1444
+ </SheetTitle>
1445
+ <SheetDescription>
1446
+ {isEditing
1447
+ ? t('sheetEditDescription')
1448
+ : allowCompanyRegistration
1449
+ ? t('sheetCreateDescription')
1450
+ : t('sheetCreateDescriptionIndividualOnly')}
1451
+ </SheetDescription>
1452
+ </div>
1254
1453
  </div>
1255
- </div>
1256
- </SheetHeader>
1454
+ </SheetHeader>
1257
1455
 
1258
- <div className="flex-1 overflow-y-auto">
1259
- <form
1260
- id="person-form"
1261
- onSubmit={handleSubmit(handleFormSubmit)}
1262
- className="space-y-4 px-4 [&_button[role='combobox']]:text-xs [&_button[role='combobox']_span]:truncate [&_input]:text-xs"
1263
- >
1264
- <div className="space-y-3">
1265
- <h3 className="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
1266
- {t('dialogBasicInformationTitle')}
1267
- </h3>
1268
-
1269
- <div className="flex items-start gap-3">
1270
- <Avatar className="h-16 w-16 border rounded-md">
1271
- <AvatarImage
1272
- src={avatarPreviewUrl}
1273
- alt={watch('name') || t('name')}
1274
- className="rounded-md object-cover"
1275
- />
1276
- <AvatarFallback className="text-sm font-semibold uppercase">
1277
- {getPersonInitials(watch('name') || person?.name || 'NA') ||
1278
- 'NA'}
1279
- </AvatarFallback>
1280
- </Avatar>
1281
-
1282
- <div className="min-w-0 flex-1 space-y-2">
1283
- <input
1284
- ref={fileInputRef}
1285
- type="file"
1286
- accept="image/*"
1287
- className="hidden"
1288
- onChange={(event) => {
1289
- const file = event.target.files?.[0];
1290
- if (!file) return;
1291
- void handleAvatarUpload(file);
1292
- }}
1293
- />
1456
+ <div className="flex-1 overflow-y-auto">
1457
+ <form
1458
+ id="person-form"
1459
+ onSubmit={handleSubmit(handleFormSubmit)}
1460
+ className="space-y-4 px-4 [&_button[role='combobox']]:text-xs [&_button[role='combobox']_span]:truncate [&_input]:text-xs"
1461
+ >
1462
+ <div className="space-y-3">
1463
+ <h3 className="text-sm font-semibold tracking-wider text-muted-foreground uppercase">
1464
+ {t('dialogBasicInformationTitle')}
1465
+ </h3>
1466
+
1467
+ <div className="flex items-start gap-3">
1468
+ <Avatar className="h-16 w-16 border rounded-md">
1469
+ <AvatarImage
1470
+ src={avatarPreviewUrl}
1471
+ alt={watch('name') || t('name')}
1472
+ className="rounded-md object-cover"
1473
+ />
1474
+ <AvatarFallback className="text-sm font-semibold uppercase">
1475
+ {getPersonInitials(
1476
+ watch('name') || person?.name || 'NA'
1477
+ ) || 'NA'}
1478
+ </AvatarFallback>
1479
+ </Avatar>
1480
+
1481
+ <div className="min-w-0 flex-1 space-y-2">
1482
+ <input
1483
+ ref={fileInputRef}
1484
+ type="file"
1485
+ accept="image/*"
1486
+ className="hidden"
1487
+ onChange={(event) => {
1488
+ const file = event.target.files?.[0];
1489
+ if (!file) return;
1490
+ void handleAvatarUpload(file);
1491
+ }}
1492
+ />
1294
1493
 
1295
- <div className="flex flex-wrap gap-2">
1296
- <Button
1297
- type="button"
1298
- variant="outline"
1299
- size="sm"
1300
- onClick={handleSelectAvatar}
1301
- disabled={isUploadingAvatar}
1302
- >
1303
- {isUploadingAvatar ? (
1304
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1305
- ) : (
1306
- <Upload className="mr-2 h-4 w-4" />
1307
- )}
1308
- {avatarId ? t('avatarReplace') : t('avatarUpload')}
1309
- </Button>
1310
- {avatarId || isUploadingAvatar ? (
1494
+ <div className="flex flex-wrap gap-2">
1311
1495
  <Button
1312
1496
  type="button"
1313
- variant="ghost"
1497
+ variant="outline"
1314
1498
  size="sm"
1315
- className="text-red-600 hover:text-red-700"
1316
- onClick={() => void handleRemoveAvatar()}
1317
- disabled={!avatarId || isUploadingAvatar}
1499
+ onClick={handleSelectAvatar}
1500
+ disabled={isUploadingAvatar}
1318
1501
  >
1319
- <Trash2 className="mr-2 h-4 w-4" />
1320
- {t('avatarRemove')}
1502
+ {isUploadingAvatar ? (
1503
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1504
+ ) : (
1505
+ <Upload className="mr-2 h-4 w-4" />
1506
+ )}
1507
+ {avatarId ? t('avatarReplace') : t('avatarUpload')}
1321
1508
  </Button>
1322
- ) : null}
1323
- </div>
1324
-
1325
- <p className="text-xs text-muted-foreground">
1326
- {t('avatarGuidelines')}
1327
- </p>
1328
-
1329
- {isUploadingAvatar ? (
1330
- <div className="space-y-1">
1331
- <Progress value={avatarUploadProgress} className="h-2" />
1332
- <p className="text-xs text-muted-foreground">
1333
- {t('avatarUploadingProgress', {
1334
- progress: avatarUploadProgress,
1335
- })}
1336
- </p>
1509
+ {avatarId || isUploadingAvatar ? (
1510
+ <Button
1511
+ type="button"
1512
+ variant="ghost"
1513
+ size="sm"
1514
+ className="text-red-600 hover:text-red-700"
1515
+ onClick={() => void handleRemoveAvatar()}
1516
+ disabled={!avatarId || isUploadingAvatar}
1517
+ >
1518
+ <Trash2 className="mr-2 h-4 w-4" />
1519
+ {t('avatarRemove')}
1520
+ </Button>
1521
+ ) : null}
1337
1522
  </div>
1338
- ) : null}
1339
- </div>
1340
- </div>
1341
1523
 
1342
- <div className="space-y-1.5">
1343
- <Label className="text-xs font-medium">
1344
- {t('name')} <span className="text-destructive">*</span>
1345
- </Label>
1346
- <Input
1347
- placeholder={t('namePlaceholder')}
1348
- {...register('name')}
1349
- className={cn('h-9', errors.name && 'border-destructive')}
1350
- />
1351
- {errors.name ? (
1352
- <p className="text-xs text-destructive">
1353
- {errors.name.message}
1354
- </p>
1355
- ) : null}
1356
- </div>
1524
+ <p className="text-xs text-muted-foreground">
1525
+ {t('avatarGuidelines')}
1526
+ </p>
1357
1527
 
1358
- <div
1359
- className={cn(
1360
- 'grid gap-3',
1361
- watchType === 'individual'
1362
- ? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
1363
- : 'grid-cols-1 sm:grid-cols-2'
1364
- )}
1365
- >
1366
- {allowCompanyRegistration ? (
1367
- <div className="space-y-1.5">
1368
- <Label className="text-xs font-medium">{t('type')}</Label>
1369
- <Select
1370
- value={watch('type')}
1371
- onValueChange={(value: 'individual' | 'company') =>
1372
- setValue('type', value)
1373
- }
1374
- >
1375
- <SelectTrigger className="h-9 w-full min-w-0">
1376
- <SelectValue />
1377
- </SelectTrigger>
1378
- <SelectContent>
1379
- <SelectItem value="individual">
1380
- {t('individual')}
1381
- </SelectItem>
1382
- {canUseCompanyType ? (
1383
- <SelectItem value="company">
1384
- {t('company')}
1385
- </SelectItem>
1386
- ) : null}
1387
- </SelectContent>
1388
- </Select>
1528
+ {isUploadingAvatar ? (
1529
+ <div className="space-y-1">
1530
+ <Progress
1531
+ value={avatarUploadProgress}
1532
+ className="h-2"
1533
+ />
1534
+ <p className="text-xs text-muted-foreground">
1535
+ {t('avatarUploadingProgress', {
1536
+ progress: avatarUploadProgress,
1537
+ })}
1538
+ </p>
1539
+ </div>
1540
+ ) : null}
1389
1541
  </div>
1390
- ) : null}
1391
- <div className="space-y-1.5">
1392
- <Label className="text-xs font-medium">{t('status')}</Label>
1393
- <Select
1394
- value={watch('status')}
1395
- onValueChange={(value: 'active' | 'inactive') =>
1396
- setValue('status', value)
1397
- }
1398
- >
1399
- <SelectTrigger className="h-9 w-full min-w-0">
1400
- <SelectValue />
1401
- </SelectTrigger>
1402
- <SelectContent>
1403
- <SelectItem value="active">{t('active')}</SelectItem>
1404
- <SelectItem value="inactive">{t('inactive')}</SelectItem>
1405
- </SelectContent>
1406
- </Select>
1407
1542
  </div>
1408
1543
 
1409
- {watchType === 'individual' ? (
1410
- <>
1411
- <div className="space-y-1.5">
1412
- <Label className="text-xs font-medium">
1413
- {t('birthDate')}
1414
- </Label>
1415
- <DatePickerWithYearMonth
1416
- date={watch('birth_date') || undefined}
1417
- onSelect={(date) =>
1418
- setValue('birth_date', date || null)
1419
- }
1420
- maxDate={new Date()}
1421
- placeholder={t('selectDate')}
1422
- localeCode={currentLocaleCode}
1423
- />
1424
- </div>
1544
+ <div className="space-y-1.5">
1545
+ <Label className="text-xs font-medium">
1546
+ {t('name')} <span className="text-destructive">*</span>
1547
+ </Label>
1548
+ <Input
1549
+ placeholder={t('namePlaceholder')}
1550
+ {...register('name')}
1551
+ className={cn('h-9', errors.name && 'border-destructive')}
1552
+ />
1553
+ {errors.name ? (
1554
+ <p className="text-xs text-destructive">
1555
+ {errors.name.message}
1556
+ </p>
1557
+ ) : null}
1558
+ </div>
1425
1559
 
1560
+ <div
1561
+ className={cn(
1562
+ 'grid gap-3',
1563
+ watchType === 'individual'
1564
+ ? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
1565
+ : 'grid-cols-1 sm:grid-cols-2'
1566
+ )}
1567
+ >
1568
+ {allowCompanyRegistration ? (
1426
1569
  <div className="space-y-1.5">
1427
- <Label className="text-xs font-medium">
1428
- {t('gender')}
1429
- </Label>
1570
+ <Label className="text-xs font-medium">{t('type')}</Label>
1430
1571
  <Select
1431
- value={watch('gender') || ''}
1432
- onValueChange={(value: PersonGender) =>
1433
- setValue('gender', value)
1572
+ value={watch('type')}
1573
+ onValueChange={(value: 'individual' | 'company') =>
1574
+ setValue('type', value)
1434
1575
  }
1435
1576
  >
1436
1577
  <SelectTrigger className="h-9 w-full min-w-0">
1437
- <SelectValue placeholder={t('selectGender')} />
1578
+ <SelectValue />
1438
1579
  </SelectTrigger>
1439
1580
  <SelectContent>
1440
- <SelectItem value="male">
1441
- {t('genderMale')}
1442
- </SelectItem>
1443
- <SelectItem value="female">
1444
- {t('genderFemale')}
1445
- </SelectItem>
1446
- <SelectItem value="other">
1447
- {t('genderOther')}
1581
+ <SelectItem value="individual">
1582
+ {t('individual')}
1448
1583
  </SelectItem>
1584
+ {canUseCompanyType ? (
1585
+ <SelectItem value="company">
1586
+ {t('company')}
1587
+ </SelectItem>
1588
+ ) : null}
1449
1589
  </SelectContent>
1450
1590
  </Select>
1451
1591
  </div>
1452
- </>
1453
- ) : null}
1454
- </div>
1455
-
1456
- <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
1457
- <div className="space-y-1.5">
1458
- <Label className="text-xs font-medium">{t('owner')}</Label>
1459
- <Select
1460
- value={
1461
- watch('owner_user_id')
1462
- ? String(watch('owner_user_id'))
1463
- : 'none'
1464
- }
1465
- onValueChange={(value) =>
1466
- setValue(
1467
- 'owner_user_id',
1468
- value === 'none' ? null : Number(value)
1469
- )
1470
- }
1471
- >
1472
- <SelectTrigger className="h-9 w-full min-w-0">
1473
- <SelectValue placeholder={t('unassigned')} />
1474
- </SelectTrigger>
1475
- <SelectContent>
1476
- <SelectItem value="none">{t('unassigned')}</SelectItem>
1477
- {ownerOptions.map((owner) => (
1478
- <SelectItem key={owner.id} value={String(owner.id)}>
1479
- {owner.name}
1592
+ ) : null}
1593
+ <div className="space-y-1.5">
1594
+ <Label className="text-xs font-medium">{t('status')}</Label>
1595
+ <Select
1596
+ value={watch('status')}
1597
+ onValueChange={(value: 'active' | 'inactive') =>
1598
+ setValue('status', value)
1599
+ }
1600
+ >
1601
+ <SelectTrigger className="h-9 w-full min-w-0">
1602
+ <SelectValue />
1603
+ </SelectTrigger>
1604
+ <SelectContent>
1605
+ <SelectItem value="active">{t('active')}</SelectItem>
1606
+ <SelectItem value="inactive">
1607
+ {t('inactive')}
1480
1608
  </SelectItem>
1481
- ))}
1482
- </SelectContent>
1483
- </Select>
1484
- </div>
1485
-
1486
- <div className="space-y-1.5">
1487
- <Label className="text-xs font-medium">{t('source')}</Label>
1488
- <Select
1489
- value={watch('source') || 'none'}
1490
- onValueChange={(value) =>
1491
- setValue(
1492
- 'source',
1493
- value === 'none' ? null : (value as PersonSource)
1494
- )
1495
- }
1496
- >
1497
- <SelectTrigger className="h-9 w-full min-w-0">
1498
- <SelectValue placeholder={t('source')} />
1499
- </SelectTrigger>
1500
- <SelectContent>
1501
- <SelectItem value="none">{t('all')}</SelectItem>
1502
- <SelectItem value="referral">
1503
- {t('sourceReferral')}
1504
- </SelectItem>
1505
- <SelectItem value="website">
1506
- {t('sourceWebsite')}
1507
- </SelectItem>
1508
- <SelectItem value="social">
1509
- {t('sourceSocial')}
1510
- </SelectItem>
1511
- <SelectItem value="inbound">
1512
- {t('sourceInbound')}
1513
- </SelectItem>
1514
- <SelectItem value="outbound">
1515
- {t('sourceOutbound')}
1516
- </SelectItem>
1517
- <SelectItem value="other">{t('sourceOther')}</SelectItem>
1518
- </SelectContent>
1519
- </Select>
1520
- </div>
1521
-
1522
- <div className="space-y-1.5">
1523
- <Label className="text-xs font-medium">
1524
- {t('lifecycleStage')}
1525
- </Label>
1526
- <Select
1527
- value={watch('lifecycle_stage') || 'new'}
1528
- onValueChange={(value) =>
1529
- setValue('lifecycle_stage', value as PersonLifecycleStage)
1530
- }
1531
- >
1532
- <SelectTrigger className="h-9 w-full min-w-0">
1533
- <SelectValue />
1534
- </SelectTrigger>
1535
- <SelectContent>
1536
- <SelectItem value="new">{t('lifecycleNew')}</SelectItem>
1537
- <SelectItem value="contacted">
1538
- {t('lifecycleContacted')}
1539
- </SelectItem>
1540
- <SelectItem value="qualified">
1541
- {t('lifecycleQualified')}
1542
- </SelectItem>
1543
- <SelectItem value="proposal">
1544
- {t('lifecycleProposal')}
1545
- </SelectItem>
1546
- <SelectItem value="negotiation">
1547
- {t('lifecycleNegotiation')}
1548
- </SelectItem>
1549
- <SelectItem value="customer">
1550
- {t('lifecycleCustomer')}
1551
- </SelectItem>
1552
- <SelectItem value="lost">{t('lifecycleLost')}</SelectItem>
1553
- </SelectContent>
1554
- </Select>
1555
- </div>
1556
-
1557
- <div className="space-y-1.5">
1558
- <Label className="text-xs font-medium">
1559
- {t('nextActionAt')}
1560
- </Label>
1561
- <Input
1562
- type="datetime-local"
1563
- value={watch('next_action_at') || ''}
1564
- onChange={(event) =>
1565
- setValue('next_action_at', event.target.value)
1566
- }
1567
- className="h-9"
1568
- />
1569
- </div>
1570
- </div>
1571
-
1572
- {watchType === 'individual' ? (
1573
- <>
1574
- <div className="grid grid-cols-2 gap-3">
1575
- <div className="space-y-1.5">
1576
- <Label className="text-xs font-medium">
1577
- {t('jobTitle')}
1578
- </Label>
1579
- <Input
1580
- placeholder={t('jobTitlePlaceholder')}
1581
- {...register('job_title')}
1582
- className="h-9"
1583
- />
1584
- </div>
1609
+ </SelectContent>
1610
+ </Select>
1611
+ </div>
1585
1612
 
1586
- {allowCompanyRegistration ? (
1613
+ {watchType === 'individual' ? (
1614
+ <>
1587
1615
  <div className="space-y-1.5">
1588
- <PersonFieldWithCreate<PersonFormValues>
1589
- form={form}
1590
- name="employer_company_id"
1591
- label={t('employerCompany')}
1592
- entityLabel={t('company')}
1593
- selectPlaceholder={t('employerCompanyPlaceholder')}
1594
- personTypeFilter="company"
1595
- createType="company"
1596
- lockCreateType
1597
- valueType="number"
1598
- initialSelectedLabel={
1599
- person?.employer_company?.name || ''
1616
+ <Label className="text-xs font-medium">
1617
+ {t('birthDate')}
1618
+ </Label>
1619
+ <DatePickerWithYearMonth
1620
+ date={watch('birth_date') || undefined}
1621
+ onSelect={(date) =>
1622
+ setValue('birth_date', date || null)
1600
1623
  }
1624
+ maxDate={new Date()}
1625
+ placeholder={t('selectDate')}
1626
+ localeCode={currentLocaleCode}
1601
1627
  />
1602
1628
  </div>
1603
- ) : null}
1604
- </div>
1605
- </>
1606
- ) : (
1607
- <>
1608
- <div className="space-y-1.5">
1609
- <Label className="text-xs font-medium">
1610
- {t('tradeName')}
1611
- </Label>
1612
- <Input
1613
- placeholder={t('tradeNamePlaceholder')}
1614
- {...register('trade_name')}
1615
- className="h-9"
1616
- />
1617
- </div>
1618
1629
 
1619
- <div className="grid grid-cols-2 gap-3">
1620
- <div className="space-y-1.5">
1621
- <Label className="text-xs font-medium">
1622
- {t('foundationDate')}
1623
- </Label>
1624
- <DatePickerWithYearMonth
1625
- date={watch('foundation_date') || undefined}
1626
- onSelect={(date) =>
1627
- setValue('foundation_date', date || null)
1628
- }
1629
- maxDate={new Date()}
1630
- placeholder={t('selectDate')}
1631
- localeCode={currentLocaleCode}
1632
- />
1633
- </div>
1630
+ <div className="space-y-1.5">
1631
+ <Label className="text-xs font-medium">
1632
+ {t('gender')}
1633
+ </Label>
1634
+ <Select
1635
+ value={watch('gender') || ''}
1636
+ onValueChange={(value: PersonGender) =>
1637
+ setValue('gender', value)
1638
+ }
1639
+ >
1640
+ <SelectTrigger className="h-9 w-full min-w-0">
1641
+ <SelectValue placeholder={t('selectGender')} />
1642
+ </SelectTrigger>
1643
+ <SelectContent>
1644
+ <SelectItem value="male">
1645
+ {t('genderMale')}
1646
+ </SelectItem>
1647
+ <SelectItem value="female">
1648
+ {t('genderFemale')}
1649
+ </SelectItem>
1650
+ <SelectItem value="other">
1651
+ {t('genderOther')}
1652
+ </SelectItem>
1653
+ </SelectContent>
1654
+ </Select>
1655
+ </div>
1656
+ </>
1657
+ ) : null}
1658
+ </div>
1634
1659
 
1635
- <div className="space-y-1.5">
1636
- <Label className="text-xs font-medium">
1637
- {t('legalNature')}
1638
- </Label>
1639
- <Input
1640
- placeholder={t('legalNaturePlaceholder')}
1641
- {...register('legal_nature')}
1642
- className="h-9"
1643
- />
1644
- </div>
1660
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
1661
+ <div className="space-y-1.5">
1662
+ <Label className="text-xs font-medium">{t('owner')}</Label>
1663
+ <Select
1664
+ value={
1665
+ watch('owner_user_id')
1666
+ ? String(watch('owner_user_id'))
1667
+ : 'none'
1668
+ }
1669
+ onValueChange={(value) =>
1670
+ setValue(
1671
+ 'owner_user_id',
1672
+ value === 'none' ? null : Number(value)
1673
+ )
1674
+ }
1675
+ >
1676
+ <SelectTrigger className="h-9 w-full min-w-0">
1677
+ <SelectValue placeholder={t('unassigned')} />
1678
+ </SelectTrigger>
1679
+ <SelectContent>
1680
+ <SelectItem value="none">{t('unassigned')}</SelectItem>
1681
+ {ownerOptions.map((owner) => (
1682
+ <SelectItem key={owner.id} value={String(owner.id)}>
1683
+ {owner.name}
1684
+ </SelectItem>
1685
+ ))}
1686
+ </SelectContent>
1687
+ </Select>
1645
1688
  </div>
1646
- </>
1647
- )}
1648
- </div>
1649
1689
 
1650
- <Separator />
1651
-
1652
- <Collapsible open={contactsOpen} onOpenChange={setContactsOpen}>
1653
- <CollapsibleTrigger asChild>
1654
- <div className="group flex cursor-pointer items-center justify-between">
1655
- <div className="flex items-center gap-2">
1656
- <Mail className="h-4 w-4 text-blue-500" />
1657
- <h3 className="text-sm font-semibold">
1658
- {t('tabContacts')}
1659
- </h3>
1660
- {contacts.length > 0 ? (
1661
- <Badge
1662
- variant="secondary"
1663
- className="bg-blue-500/10 text-blue-600"
1664
- >
1665
- {contacts.length}
1666
- </Badge>
1667
- ) : null}
1690
+ <div className="space-y-1.5">
1691
+ <Label className="text-xs font-medium">{t('source')}</Label>
1692
+ <Select
1693
+ value={watch('source') || 'none'}
1694
+ onValueChange={(value) =>
1695
+ setValue(
1696
+ 'source',
1697
+ value === 'none' ? null : (value as PersonSource)
1698
+ )
1699
+ }
1700
+ >
1701
+ <SelectTrigger className="h-9 w-full min-w-0">
1702
+ <SelectValue placeholder={t('source')} />
1703
+ </SelectTrigger>
1704
+ <SelectContent>
1705
+ <SelectItem value="none">{t('all')}</SelectItem>
1706
+ <SelectItem value="referral">
1707
+ {t('sourceReferral')}
1708
+ </SelectItem>
1709
+ <SelectItem value="website">
1710
+ {t('sourceWebsite')}
1711
+ </SelectItem>
1712
+ <SelectItem value="social">
1713
+ {t('sourceSocial')}
1714
+ </SelectItem>
1715
+ <SelectItem value="inbound">
1716
+ {t('sourceInbound')}
1717
+ </SelectItem>
1718
+ <SelectItem value="outbound">
1719
+ {t('sourceOutbound')}
1720
+ </SelectItem>
1721
+ <SelectItem value="other">
1722
+ {t('sourceOther')}
1723
+ </SelectItem>
1724
+ </SelectContent>
1725
+ </Select>
1668
1726
  </div>
1669
1727
 
1670
- <div className="flex items-center gap-1">
1671
- <Button
1672
- type="button"
1673
- variant="ghost"
1674
- size="sm"
1675
- className="h-7 px-2 text-xs"
1676
- onClick={(event) => {
1677
- event.stopPropagation();
1678
- addContact('email');
1679
- }}
1680
- >
1681
- <Plus className="mr-1 h-3.5 w-3.5" />
1682
- {t('addEmail')}
1683
- </Button>
1684
- <Button
1685
- type="button"
1686
- variant="ghost"
1687
- size="sm"
1688
- className="h-7 px-2 text-xs"
1689
- onClick={(event) => {
1690
- event.stopPropagation();
1691
- addContact('phone');
1692
- }}
1693
- >
1694
- <Plus className="mr-1 h-3.5 w-3.5" />
1695
- {t('addPhone')}
1696
- </Button>
1697
- <Button
1698
- type="button"
1699
- variant="ghost"
1700
- size="sm"
1701
- className="h-7 px-2 text-xs"
1702
- onClick={(event) => {
1703
- event.stopPropagation();
1704
- addContact('blank');
1705
- }}
1728
+ <div className="space-y-1.5">
1729
+ <Label className="text-xs font-medium">
1730
+ {t('lifecycleStage')}
1731
+ </Label>
1732
+ <Select
1733
+ value={watch('lifecycle_stage') || 'new'}
1734
+ onValueChange={(value) =>
1735
+ setValue(
1736
+ 'lifecycle_stage',
1737
+ value as PersonLifecycleStage
1738
+ )
1739
+ }
1706
1740
  >
1707
- <Plus className="mr-1 h-3.5 w-3.5" />
1708
- {t('addContact')}
1709
- </Button>
1710
- {contactsOpen ? (
1711
- <ChevronUp className="h-4 w-4" />
1712
- ) : (
1713
- <ChevronDown className="h-4 w-4" />
1714
- )}
1741
+ <SelectTrigger className="h-9 w-full min-w-0">
1742
+ <SelectValue />
1743
+ </SelectTrigger>
1744
+ <SelectContent>
1745
+ <SelectItem value="new">{t('lifecycleNew')}</SelectItem>
1746
+ <SelectItem value="contacted">
1747
+ {t('lifecycleContacted')}
1748
+ </SelectItem>
1749
+ <SelectItem value="qualified">
1750
+ {t('lifecycleQualified')}
1751
+ </SelectItem>
1752
+ <SelectItem value="proposal">
1753
+ {t('lifecycleProposal')}
1754
+ </SelectItem>
1755
+ <SelectItem value="negotiation">
1756
+ {t('lifecycleNegotiation')}
1757
+ </SelectItem>
1758
+ <SelectItem value="customer">
1759
+ {t('lifecycleCustomer')}
1760
+ </SelectItem>
1761
+ <SelectItem value="lost">
1762
+ {t('lifecycleLost')}
1763
+ </SelectItem>
1764
+ </SelectContent>
1765
+ </Select>
1715
1766
  </div>
1716
- </div>
1717
- </CollapsibleTrigger>
1718
1767
 
1719
- <CollapsibleContent className="mt-2 space-y-2">
1720
- {contacts.length === 0 ? (
1721
- <div className="rounded-lg border-2 border-dashed py-4 text-center text-sm text-muted-foreground">
1722
- {t('noContacts')}
1768
+ <div className="space-y-1.5">
1769
+ <Label className="text-xs font-medium">
1770
+ {t('nextActionAt')}
1771
+ </Label>
1772
+ <Input
1773
+ type="datetime-local"
1774
+ value={watch('next_action_at') || ''}
1775
+ onChange={(event) =>
1776
+ setValue('next_action_at', event.target.value)
1777
+ }
1778
+ className="h-9"
1779
+ />
1723
1780
  </div>
1724
- ) : (
1725
- contacts.map((contact) => (
1726
- <div
1727
- key={contact.clientId}
1728
- className={cn(
1729
- 'space-y-2 rounded-lg border p-2',
1730
- contact.is_primary && 'border-blue-500/50 bg-blue-500/5'
1731
- )}
1732
- >
1733
- <div className="flex items-center gap-2">
1734
- <Select
1735
- value={
1736
- contact.contact_type_id
1737
- ? String(contact.contact_type_id)
1738
- : undefined
1739
- }
1740
- onValueChange={(value) => {
1741
- const nextTypeId = Number(value);
1742
- updateContact(contact.clientId, {
1743
- contact_type_id: nextTypeId,
1744
- value: maskContactValueByType(
1745
- contact.value || '',
1746
- nextTypeId
1747
- ),
1748
- });
1749
- }}
1750
- >
1751
- <SelectTrigger className="h-8 w-36 text-xs">
1752
- <SelectValue placeholder={t('selectContactType')} />
1753
- </SelectTrigger>
1754
- <SelectContent>
1755
- {contactTypes.map((contactType) => (
1756
- <SelectItem
1757
- key={contactType.contact_type_id}
1758
- value={String(contactType.contact_type_id)}
1759
- >
1760
- {contactType.name}
1761
- </SelectItem>
1762
- ))}
1763
- </SelectContent>
1764
- </Select>
1781
+ </div>
1765
1782
 
1783
+ {watchType === 'individual' ? (
1784
+ <>
1785
+ <div className="grid grid-cols-2 gap-3">
1786
+ <div className="space-y-1.5">
1787
+ <Label className="text-xs font-medium">
1788
+ {t('jobTitle')}
1789
+ </Label>
1766
1790
  <Input
1767
- ref={(element) => {
1768
- contactValueRefs.current[contact.clientId] =
1769
- element;
1770
- }}
1771
- placeholder={(() => {
1772
- const contactTypeCode = getContactTypeCode(
1773
- contact.contact_type_id
1774
- );
1775
- if (contactTypeCode === 'EMAIL') {
1776
- return 'email@exemplo.com';
1777
- }
1778
- if (contactTypeCode.length === 0) {
1779
- return t('contactValue');
1791
+ placeholder={t('jobTitlePlaceholder')}
1792
+ {...register('job_title')}
1793
+ className="h-9"
1794
+ />
1795
+ </div>
1796
+
1797
+ {allowCompanyRegistration ? (
1798
+ <div className="space-y-1.5">
1799
+ <PersonFieldWithCreate<PersonFormValues>
1800
+ form={form}
1801
+ name="employer_company_id"
1802
+ label={t('employerCompany')}
1803
+ entityLabel={t('company')}
1804
+ selectPlaceholder={t('employerCompanyPlaceholder')}
1805
+ personTypeFilter="company"
1806
+ createType="company"
1807
+ lockCreateType
1808
+ valueType="number"
1809
+ initialSelectedLabel={
1810
+ person?.employer_company?.name || ''
1780
1811
  }
1781
- return '(00) 00000-0000';
1782
- })()}
1783
- value={contact.value}
1784
- onChange={(event) =>
1785
- updateContact(contact.clientId, {
1786
- value: maskContactValueByType(
1787
- event.target.value,
1788
- contact.contact_type_id
1789
- ),
1790
- })
1812
+ />
1813
+ </div>
1814
+ ) : null}
1815
+ </div>
1816
+ </>
1817
+ ) : (
1818
+ <>
1819
+ <div className="space-y-1.5">
1820
+ <Label className="text-xs font-medium">
1821
+ {t('tradeName')}
1822
+ </Label>
1823
+ <Input
1824
+ placeholder={t('tradeNamePlaceholder')}
1825
+ {...register('trade_name')}
1826
+ className="h-9"
1827
+ />
1828
+ </div>
1829
+
1830
+ <div className="grid grid-cols-2 gap-3">
1831
+ <div className="space-y-1.5">
1832
+ <Label className="text-xs font-medium">
1833
+ {t('foundationDate')}
1834
+ </Label>
1835
+ <DatePickerWithYearMonth
1836
+ date={watch('foundation_date') || undefined}
1837
+ onSelect={(date) =>
1838
+ setValue('foundation_date', date || null)
1791
1839
  }
1792
- className="h-8 flex-1 text-xs"
1840
+ maxDate={new Date()}
1841
+ placeholder={t('selectDate')}
1842
+ localeCode={currentLocaleCode}
1793
1843
  />
1844
+ </div>
1794
1845
 
1795
- <Tooltip>
1796
- <TooltipTrigger asChild>
1797
- <Button
1798
- type="button"
1799
- variant="ghost"
1800
- size="icon"
1801
- className={cn(
1802
- 'h-8 w-8',
1803
- contact.is_primary && 'text-amber-500'
1804
- )}
1805
- onClick={() =>
1806
- setPrimaryContact(contact.clientId)
1807
- }
1808
- aria-label={t('main')}
1809
- >
1810
- <Star
1811
- className={cn(
1812
- 'h-4 w-4',
1813
- contact.is_primary && 'fill-current'
1814
- )}
1815
- />
1816
- </Button>
1817
- </TooltipTrigger>
1818
- <TooltipContent>{t('main')}</TooltipContent>
1819
- </Tooltip>
1820
-
1821
- <Tooltip>
1822
- <TooltipTrigger asChild>
1823
- <Button
1824
- type="button"
1825
- variant="ghost"
1826
- size="icon"
1827
- className="h-8 w-8 text-red-500 hover:text-red-600"
1828
- onClick={() => removeContact(contact.clientId)}
1829
- aria-label={t('remove')}
1830
- >
1831
- <Trash2 className="h-4 w-4" />
1832
- </Button>
1833
- </TooltipTrigger>
1834
- <TooltipContent>{t('remove')}</TooltipContent>
1835
- </Tooltip>
1846
+ <div className="space-y-1.5">
1847
+ <Label className="text-xs font-medium">
1848
+ {t('legalNature')}
1849
+ </Label>
1850
+ <Input
1851
+ placeholder={t('legalNaturePlaceholder')}
1852
+ {...register('legal_nature')}
1853
+ className="h-9"
1854
+ />
1836
1855
  </div>
1837
1856
  </div>
1838
- ))
1857
+ </>
1839
1858
  )}
1840
- </CollapsibleContent>
1841
- </Collapsible>
1842
-
1843
- <Separator />
1844
-
1845
- <Collapsible open={addressesOpen} onOpenChange={setAddressesOpen}>
1846
- <CollapsibleTrigger asChild>
1847
- <div className="group flex cursor-pointer items-center justify-between">
1848
- <div className="flex items-center gap-2">
1849
- <MapPin className="h-4 w-4 text-green-500" />
1850
- <h3 className="text-sm font-semibold">
1851
- {t('tabAddresses')}
1852
- </h3>
1853
- {addresses.length > 0 ? (
1854
- <Badge
1855
- variant="secondary"
1856
- className="bg-green-500/10 text-green-600"
1857
- >
1858
- {addresses.length}
1859
- </Badge>
1860
- ) : null}
1861
- </div>
1862
-
1863
- <div className="flex items-center gap-2">
1864
- <Button
1865
- type="button"
1866
- variant="ghost"
1867
- size="sm"
1868
- className="h-7 px-2 text-xs"
1869
- onClick={(event) => {
1870
- event.stopPropagation();
1871
- addAddress();
1872
- }}
1873
- >
1874
- <Plus className="mr-1 h-3.5 w-3.5" />
1875
- {t('addAddress')}
1876
- </Button>
1877
- {addressesOpen ? (
1878
- <ChevronUp className="h-4 w-4" />
1879
- ) : (
1880
- <ChevronDown className="h-4 w-4" />
1881
- )}
1882
- </div>
1883
- </div>
1884
- </CollapsibleTrigger>
1859
+ </div>
1885
1860
 
1886
- <CollapsibleContent className="mt-2 space-y-2">
1887
- {addresses.length === 0 ? (
1888
- <div className="rounded-lg border-2 border-dashed py-4 text-center text-sm text-muted-foreground">
1889
- {t('noAddresses')}
1890
- </div>
1891
- ) : (
1892
- addresses.map((address) => (
1893
- <div
1894
- key={address.clientId}
1895
- className={cn(
1896
- 'space-y-2 rounded-lg border p-2',
1897
- address.is_primary &&
1898
- 'border-green-500/50 bg-green-500/5'
1899
- )}
1900
- >
1901
- <div className="flex items-center gap-2">
1902
- <Select
1903
- value={address.address_type}
1904
- onValueChange={(value) =>
1905
- updateAddress(address.clientId, {
1906
- address_type:
1907
- value as PersonAddress['address_type'],
1908
- })
1909
- }
1861
+ <Separator />
1862
+
1863
+ <Collapsible open={contactsOpen} onOpenChange={setContactsOpen}>
1864
+ <CollapsibleTrigger asChild>
1865
+ <div className="group flex cursor-pointer items-center justify-between">
1866
+ <div className="flex items-center gap-2">
1867
+ <Mail className="h-4 w-4 text-blue-500" />
1868
+ <h3 className="text-sm font-semibold">
1869
+ {t('tabContacts')}
1870
+ </h3>
1871
+ {contacts.length > 0 ? (
1872
+ <Badge
1873
+ variant="secondary"
1874
+ className="bg-blue-500/10 text-blue-600"
1910
1875
  >
1911
- <SelectTrigger className="h-8 w-36 text-xs">
1912
- <SelectValue />
1913
- </SelectTrigger>
1914
- <SelectContent>
1915
- {ADDRESS_TYPE_OPTIONS.map((option) => (
1916
- <SelectItem
1917
- key={option.value}
1918
- value={option.value}
1919
- >
1920
- {t(option.labelKey)}
1921
- </SelectItem>
1922
- ))}
1923
- </SelectContent>
1924
- </Select>
1876
+ {contacts.length}
1877
+ </Badge>
1878
+ ) : null}
1879
+ </div>
1925
1880
 
1926
- <Input
1927
- placeholder={t('zipCode')}
1928
- value={address.postal_code || ''}
1929
- maxLength={9}
1930
- onChange={(event) =>
1931
- void handleCEP(event, address.clientId)
1932
- }
1933
- className="h-8 w-28 text-xs"
1934
- />
1881
+ <div className="flex items-center gap-1">
1882
+ <Button
1883
+ type="button"
1884
+ variant="ghost"
1885
+ size="sm"
1886
+ className="h-7 px-2 text-xs"
1887
+ onClick={(event) => {
1888
+ event.stopPropagation();
1889
+ addContact('email');
1890
+ }}
1891
+ >
1892
+ <Plus className="mr-1 h-3.5 w-3.5" />
1893
+ {t('addEmail')}
1894
+ </Button>
1895
+ <Button
1896
+ type="button"
1897
+ variant="ghost"
1898
+ size="sm"
1899
+ className="h-7 px-2 text-xs"
1900
+ onClick={(event) => {
1901
+ event.stopPropagation();
1902
+ addContact('phone');
1903
+ }}
1904
+ >
1905
+ <Plus className="mr-1 h-3.5 w-3.5" />
1906
+ {t('addPhone')}
1907
+ </Button>
1908
+ <Button
1909
+ type="button"
1910
+ variant="ghost"
1911
+ size="sm"
1912
+ className="h-7 px-2 text-xs"
1913
+ onClick={(event) => {
1914
+ event.stopPropagation();
1915
+ addContact('blank');
1916
+ }}
1917
+ >
1918
+ <Plus className="mr-1 h-3.5 w-3.5" />
1919
+ {t('addContact')}
1920
+ </Button>
1921
+ {contactsOpen ? (
1922
+ <ChevronUp className="h-4 w-4" />
1923
+ ) : (
1924
+ <ChevronDown className="h-4 w-4" />
1925
+ )}
1926
+ </div>
1927
+ </div>
1928
+ </CollapsibleTrigger>
1935
1929
 
1936
- <Tooltip>
1937
- <TooltipTrigger asChild>
1938
- <Button
1939
- type="button"
1940
- variant="ghost"
1941
- size="icon"
1942
- className={cn(
1943
- 'h-8 w-8',
1944
- address.is_primary && 'text-amber-500'
1945
- )}
1946
- onClick={() =>
1947
- setPrimaryAddress(address.clientId)
1930
+ <CollapsibleContent className="mt-2 space-y-2">
1931
+ {contacts.length === 0 ? (
1932
+ <div className="rounded-lg border-2 border-dashed py-4 text-center text-sm text-muted-foreground">
1933
+ {t('noContacts')}
1934
+ </div>
1935
+ ) : (
1936
+ contacts.map((contact) => (
1937
+ <div
1938
+ key={contact.clientId}
1939
+ className={cn(
1940
+ 'space-y-2 rounded-lg border p-2',
1941
+ contact.is_primary &&
1942
+ 'border-blue-500/50 bg-blue-500/5'
1943
+ )}
1944
+ >
1945
+ <div className="flex items-center gap-2">
1946
+ <Select
1947
+ value={
1948
+ contact.contact_type_id
1949
+ ? String(contact.contact_type_id)
1950
+ : undefined
1951
+ }
1952
+ onValueChange={(value) => {
1953
+ const nextTypeId = Number(value);
1954
+ updateContact(contact.clientId, {
1955
+ contact_type_id: nextTypeId,
1956
+ value: maskContactValueByType(
1957
+ contact.value || '',
1958
+ nextTypeId
1959
+ ),
1960
+ });
1961
+ }}
1962
+ >
1963
+ <SelectTrigger className="h-8 w-36 text-xs">
1964
+ <SelectValue
1965
+ placeholder={t('selectContactType')}
1966
+ />
1967
+ </SelectTrigger>
1968
+ <SelectContent>
1969
+ {contactTypes.map((contactType) => (
1970
+ <SelectItem
1971
+ key={contactType.contact_type_id}
1972
+ value={String(contactType.contact_type_id)}
1973
+ >
1974
+ {contactType.name}
1975
+ </SelectItem>
1976
+ ))}
1977
+ </SelectContent>
1978
+ </Select>
1979
+
1980
+ <Input
1981
+ ref={(element) => {
1982
+ contactValueRefs.current[contact.clientId] =
1983
+ element;
1984
+ }}
1985
+ placeholder={(() => {
1986
+ const contactTypeCode = getContactTypeCode(
1987
+ contact.contact_type_id
1988
+ );
1989
+ if (contactTypeCode === 'EMAIL') {
1990
+ return 'email@exemplo.com';
1948
1991
  }
1949
- aria-label={t('main')}
1950
- >
1951
- <Star
1992
+ if (contactTypeCode.length === 0) {
1993
+ return t('contactValue');
1994
+ }
1995
+ return '(00) 00000-0000';
1996
+ })()}
1997
+ value={contact.value}
1998
+ onChange={(event) =>
1999
+ updateContact(contact.clientId, {
2000
+ value: maskContactValueByType(
2001
+ event.target.value,
2002
+ contact.contact_type_id
2003
+ ),
2004
+ })
2005
+ }
2006
+ className="h-8 flex-1 text-xs"
2007
+ />
2008
+
2009
+ <Tooltip>
2010
+ <TooltipTrigger asChild>
2011
+ <Button
2012
+ type="button"
2013
+ variant="ghost"
2014
+ size="icon"
1952
2015
  className={cn(
1953
- 'h-4 w-4',
1954
- address.is_primary && 'fill-current'
2016
+ 'h-8 w-8',
2017
+ contact.is_primary && 'text-amber-500'
1955
2018
  )}
1956
- />
1957
- </Button>
1958
- </TooltipTrigger>
1959
- <TooltipContent>{t('main')}</TooltipContent>
1960
- </Tooltip>
1961
-
1962
- <Tooltip>
1963
- <TooltipTrigger asChild>
1964
- <Button
1965
- type="button"
1966
- variant="ghost"
1967
- size="icon"
1968
- className="h-8 w-8 text-red-500 hover:text-red-600"
1969
- onClick={() => removeAddress(address.clientId)}
1970
- aria-label={t('remove')}
1971
- >
1972
- <Trash2 className="h-4 w-4" />
1973
- </Button>
1974
- </TooltipTrigger>
1975
- <TooltipContent>{t('remove')}</TooltipContent>
1976
- </Tooltip>
2019
+ onClick={() =>
2020
+ setPrimaryContact(contact.clientId)
2021
+ }
2022
+ aria-label={t('main')}
2023
+ >
2024
+ <Star
2025
+ className={cn(
2026
+ 'h-4 w-4',
2027
+ contact.is_primary && 'fill-current'
2028
+ )}
2029
+ />
2030
+ </Button>
2031
+ </TooltipTrigger>
2032
+ <TooltipContent>{t('main')}</TooltipContent>
2033
+ </Tooltip>
2034
+
2035
+ <Tooltip>
2036
+ <TooltipTrigger asChild>
2037
+ <Button
2038
+ type="button"
2039
+ variant="ghost"
2040
+ size="icon"
2041
+ className="h-8 w-8 text-red-500 hover:text-red-600"
2042
+ onClick={() => removeContact(contact.clientId)}
2043
+ aria-label={t('remove')}
2044
+ >
2045
+ <Trash2 className="h-4 w-4" />
2046
+ </Button>
2047
+ </TooltipTrigger>
2048
+ <TooltipContent>{t('remove')}</TooltipContent>
2049
+ </Tooltip>
2050
+ </div>
1977
2051
  </div>
2052
+ ))
2053
+ )}
2054
+ </CollapsibleContent>
2055
+ </Collapsible>
2056
+
2057
+ <Separator />
2058
+
2059
+ <Collapsible open={addressesOpen} onOpenChange={setAddressesOpen}>
2060
+ <CollapsibleTrigger asChild>
2061
+ <div className="group flex cursor-pointer items-center justify-between">
2062
+ <div className="flex items-center gap-2">
2063
+ <MapPin className="h-4 w-4 text-green-500" />
2064
+ <h3 className="text-sm font-semibold">
2065
+ {t('tabAddresses')}
2066
+ </h3>
2067
+ {addresses.length > 0 ? (
2068
+ <Badge
2069
+ variant="secondary"
2070
+ className="bg-green-500/10 text-green-600"
2071
+ >
2072
+ {addresses.length}
2073
+ </Badge>
2074
+ ) : null}
2075
+ </div>
1978
2076
 
1979
- <div className="relative">
1980
- <Input
1981
- ref={(element) => {
1982
- addressLine1Refs.current[address.clientId] =
1983
- element;
1984
- }}
1985
- placeholder={t('addressPlaceholder')}
1986
- value={address.line1}
1987
- disabled={Boolean(loadingCEP[address.clientId])}
1988
- onChange={(event) =>
1989
- updateAddress(address.clientId, {
1990
- line1: event.target.value,
1991
- })
1992
- }
1993
- className="h-8 text-xs"
1994
- />
1995
- {loadingCEP[address.clientId] ? (
1996
- <Loader2 className="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
1997
- ) : null}
1998
- </div>
2077
+ <div className="flex items-center gap-2">
2078
+ <Button
2079
+ type="button"
2080
+ variant="ghost"
2081
+ size="sm"
2082
+ className="h-7 px-2 text-xs"
2083
+ onClick={(event) => {
2084
+ event.stopPropagation();
2085
+ addAddress();
2086
+ }}
2087
+ >
2088
+ <Plus className="mr-1 h-3.5 w-3.5" />
2089
+ {t('addAddress')}
2090
+ </Button>
2091
+ {addressesOpen ? (
2092
+ <ChevronUp className="h-4 w-4" />
2093
+ ) : (
2094
+ <ChevronDown className="h-4 w-4" />
2095
+ )}
2096
+ </div>
2097
+ </div>
2098
+ </CollapsibleTrigger>
1999
2099
 
2000
- <Input
2001
- placeholder={t('addressComplementPlaceholder')}
2002
- value={address.line2 || ''}
2003
- onChange={(event) =>
2004
- updateAddress(address.clientId, {
2005
- line2: event.target.value,
2006
- })
2007
- }
2008
- className="h-8 text-xs"
2009
- />
2100
+ <CollapsibleContent className="mt-2 space-y-2">
2101
+ {addresses.length === 0 ? (
2102
+ <div className="rounded-lg border-2 border-dashed py-4 text-center text-sm text-muted-foreground">
2103
+ {t('noAddresses')}
2104
+ </div>
2105
+ ) : (
2106
+ addresses.map((address) => (
2107
+ <div
2108
+ key={address.clientId}
2109
+ className={cn(
2110
+ 'space-y-2 rounded-lg border p-2',
2111
+ address.is_primary &&
2112
+ 'border-green-500/50 bg-green-500/5'
2113
+ )}
2114
+ >
2115
+ <div className="flex items-center gap-2">
2116
+ <Select
2117
+ value={address.address_type}
2118
+ onValueChange={(value) =>
2119
+ updateAddress(address.clientId, {
2120
+ address_type:
2121
+ value as PersonAddress['address_type'],
2122
+ })
2123
+ }
2124
+ >
2125
+ <SelectTrigger className="h-8 w-36 text-xs">
2126
+ <SelectValue />
2127
+ </SelectTrigger>
2128
+ <SelectContent>
2129
+ {ADDRESS_TYPE_OPTIONS.map((option) => (
2130
+ <SelectItem
2131
+ key={option.value}
2132
+ value={option.value}
2133
+ >
2134
+ {t(option.labelKey)}
2135
+ </SelectItem>
2136
+ ))}
2137
+ </SelectContent>
2138
+ </Select>
2139
+
2140
+ <Input
2141
+ placeholder={t('zipCode')}
2142
+ value={address.postal_code || ''}
2143
+ maxLength={9}
2144
+ onChange={(event) =>
2145
+ void handleCEP(event, address.clientId)
2146
+ }
2147
+ className="h-8 w-28 text-xs"
2148
+ />
2149
+
2150
+ <Tooltip>
2151
+ <TooltipTrigger asChild>
2152
+ <Button
2153
+ type="button"
2154
+ variant="ghost"
2155
+ size="icon"
2156
+ className={cn(
2157
+ 'h-8 w-8',
2158
+ address.is_primary && 'text-amber-500'
2159
+ )}
2160
+ onClick={() =>
2161
+ setPrimaryAddress(address.clientId)
2162
+ }
2163
+ aria-label={t('main')}
2164
+ >
2165
+ <Star
2166
+ className={cn(
2167
+ 'h-4 w-4',
2168
+ address.is_primary && 'fill-current'
2169
+ )}
2170
+ />
2171
+ </Button>
2172
+ </TooltipTrigger>
2173
+ <TooltipContent>{t('main')}</TooltipContent>
2174
+ </Tooltip>
2175
+
2176
+ <Tooltip>
2177
+ <TooltipTrigger asChild>
2178
+ <Button
2179
+ type="button"
2180
+ variant="ghost"
2181
+ size="icon"
2182
+ className="h-8 w-8 text-red-500 hover:text-red-600"
2183
+ onClick={() => removeAddress(address.clientId)}
2184
+ aria-label={t('remove')}
2185
+ >
2186
+ <Trash2 className="h-4 w-4" />
2187
+ </Button>
2188
+ </TooltipTrigger>
2189
+ <TooltipContent>{t('remove')}</TooltipContent>
2190
+ </Tooltip>
2191
+ </div>
2192
+
2193
+ <div className="relative">
2194
+ <Input
2195
+ ref={(element) => {
2196
+ addressLine1Refs.current[address.clientId] =
2197
+ element;
2198
+ }}
2199
+ placeholder={t('addressPlaceholder')}
2200
+ value={address.line1}
2201
+ disabled={Boolean(loadingCEP[address.clientId])}
2202
+ onChange={(event) =>
2203
+ updateAddress(address.clientId, {
2204
+ line1: event.target.value,
2205
+ })
2206
+ }
2207
+ className="h-8 text-xs"
2208
+ />
2209
+ {loadingCEP[address.clientId] ? (
2210
+ <Loader2 className="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" />
2211
+ ) : null}
2212
+ </div>
2010
2213
 
2011
- <div className="grid grid-cols-2 gap-2">
2012
- <Input
2013
- placeholder={t('addressCityPlaceholder')}
2014
- value={address.city}
2015
- disabled={Boolean(loadingCEP[address.clientId])}
2016
- onChange={(event) =>
2017
- updateAddress(address.clientId, {
2018
- city: event.target.value,
2019
- })
2020
- }
2021
- className="h-8 text-xs"
2022
- />
2023
2214
  <Input
2024
- placeholder={t('addressStatePlaceholder')}
2025
- value={address.state}
2026
- disabled={Boolean(loadingCEP[address.clientId])}
2215
+ placeholder={t('addressComplementPlaceholder')}
2216
+ value={address.line2 || ''}
2027
2217
  onChange={(event) =>
2028
2218
  updateAddress(address.clientId, {
2029
- state: event.target.value,
2219
+ line2: event.target.value,
2030
2220
  })
2031
2221
  }
2032
2222
  className="h-8 text-xs"
2033
2223
  />
2034
- </div>
2035
2224
 
2036
- <Select
2037
- value={address.country_code || 'BRA'}
2038
- onValueChange={(value) =>
2039
- updateAddress(address.clientId, {
2040
- country_code: value,
2041
- })
2042
- }
2043
- >
2044
- <SelectTrigger className="h-8 text-xs">
2045
- <SelectValue
2046
- placeholder={t('addressCountryPlaceholder')}
2225
+ <div className="grid grid-cols-2 gap-2">
2226
+ <Input
2227
+ placeholder={t('addressCityPlaceholder')}
2228
+ value={address.city}
2229
+ disabled={Boolean(loadingCEP[address.clientId])}
2230
+ onChange={(event) =>
2231
+ updateAddress(address.clientId, {
2232
+ city: event.target.value,
2233
+ })
2234
+ }
2235
+ className="h-8 text-xs"
2047
2236
  />
2048
- </SelectTrigger>
2049
- <SelectContent>
2050
- {COUNTRIES.map((country) => (
2051
- <SelectItem key={country.code} value={country.code}>
2052
- {country.name}
2053
- </SelectItem>
2054
- ))}
2055
- </SelectContent>
2056
- </Select>
2057
- </div>
2058
- ))
2059
- )}
2060
- </CollapsibleContent>
2061
- </Collapsible>
2062
-
2063
- <Separator />
2064
-
2065
- <Collapsible open={documentsOpen} onOpenChange={setDocumentsOpen}>
2066
- <CollapsibleTrigger asChild>
2067
- <div className="group flex cursor-pointer flex-wrap items-center gap-2">
2068
- <div className="flex min-w-0 items-center gap-2">
2069
- <FileText className="h-4 w-4 text-amber-500" />
2070
- <h3 className="text-sm font-semibold">
2071
- {t('tabDocuments')}
2072
- </h3>
2073
- {documents.length > 0 ? (
2074
- <Badge
2075
- variant="secondary"
2076
- className="bg-amber-500/10 text-amber-600"
2077
- >
2078
- {documents.length}
2079
- </Badge>
2080
- ) : null}
2081
- </div>
2082
-
2083
- <div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-1 sm:flex-nowrap">
2084
- <Button
2085
- type="button"
2086
- variant="ghost"
2087
- size="sm"
2088
- className="h-7 shrink-0 px-2 text-xs"
2089
- onClick={(event) => {
2090
- event.stopPropagation();
2091
- addDocument('cpf');
2092
- }}
2093
- >
2094
- <Plus className="mr-1 h-3.5 w-3.5" />
2095
- CPF
2096
- </Button>
2097
- <Button
2098
- type="button"
2099
- variant="ghost"
2100
- size="sm"
2101
- className="h-7 shrink-0 px-2 text-xs"
2102
- onClick={(event) => {
2103
- event.stopPropagation();
2104
- addDocument('cnpj');
2105
- }}
2106
- >
2107
- <Plus className="mr-1 h-3.5 w-3.5" />
2108
- CNPJ
2109
- </Button>
2110
- <Button
2111
- type="button"
2112
- variant="ghost"
2113
- size="sm"
2114
- className="h-7 shrink-0 px-2 text-xs"
2115
- onClick={(event) => {
2116
- event.stopPropagation();
2117
- addDocument('rg');
2118
- }}
2119
- >
2120
- <Plus className="mr-1 h-3.5 w-3.5" />
2121
- RG
2122
- </Button>
2123
- <Button
2124
- type="button"
2125
- variant="ghost"
2126
- size="sm"
2127
- className="h-7 shrink-0 px-2 text-xs"
2128
- onClick={(event) => {
2129
- event.stopPropagation();
2130
- addDocument('blank');
2131
- }}
2132
- >
2133
- <Plus className="mr-1 h-3.5 w-3.5" />
2134
- <span className="max-w-20 truncate sm:max-w-none">
2135
- {t('addDocument')}
2136
- </span>
2137
- </Button>
2138
- {documentsOpen ? (
2139
- <ChevronUp className="h-4 w-4" />
2140
- ) : (
2141
- <ChevronDown className="h-4 w-4" />
2142
- )}
2143
- </div>
2144
- </div>
2145
- </CollapsibleTrigger>
2237
+ <Input
2238
+ placeholder={t('addressStatePlaceholder')}
2239
+ value={address.state}
2240
+ disabled={Boolean(loadingCEP[address.clientId])}
2241
+ onChange={(event) =>
2242
+ updateAddress(address.clientId, {
2243
+ state: event.target.value,
2244
+ })
2245
+ }
2246
+ className="h-8 text-xs"
2247
+ />
2248
+ </div>
2146
2249
 
2147
- <CollapsibleContent className="mt-2 space-y-2">
2148
- {documents.length === 0 ? (
2149
- <div className="rounded-lg border-2 border-dashed py-4 text-center text-sm text-muted-foreground">
2150
- {t('noDocuments')}
2151
- </div>
2152
- ) : (
2153
- documents.map((document) => (
2154
- <div
2155
- key={document.clientId}
2156
- className="rounded-lg border p-2"
2157
- >
2158
- <div className="flex items-center gap-2">
2159
2250
  <Select
2160
- value={
2161
- document.document_type_id
2162
- ? String(document.document_type_id)
2163
- : undefined
2251
+ value={address.country_code || 'BRA'}
2252
+ onValueChange={(value) =>
2253
+ updateAddress(address.clientId, {
2254
+ country_code: value,
2255
+ })
2164
2256
  }
2165
- onValueChange={(value) => {
2166
- const nextTypeId = Number(value);
2167
- updateDocument(document.clientId, {
2168
- document_type_id: nextTypeId,
2169
- value: maskDocumentValueByType(
2170
- document.value || '',
2171
- nextTypeId
2172
- ),
2173
- });
2174
- }}
2175
2257
  >
2176
- <SelectTrigger className="h-8 w-36 text-xs">
2258
+ <SelectTrigger className="h-8 text-xs">
2177
2259
  <SelectValue
2178
- placeholder={t('selectDocumentType')}
2260
+ placeholder={t('addressCountryPlaceholder')}
2179
2261
  />
2180
2262
  </SelectTrigger>
2181
2263
  <SelectContent>
2182
- {documentTypes.map((documentType) => (
2264
+ {COUNTRIES.map((country) => (
2183
2265
  <SelectItem
2184
- key={documentType.document_type_id}
2185
- value={String(documentType.document_type_id)}
2266
+ key={country.code}
2267
+ value={country.code}
2186
2268
  >
2187
- {documentType.name}
2269
+ {country.name}
2188
2270
  </SelectItem>
2189
2271
  ))}
2190
2272
  </SelectContent>
2191
2273
  </Select>
2274
+ </div>
2275
+ ))
2276
+ )}
2277
+ </CollapsibleContent>
2278
+ </Collapsible>
2279
+
2280
+ <Separator />
2281
+
2282
+ <Collapsible open={documentsOpen} onOpenChange={setDocumentsOpen}>
2283
+ <CollapsibleTrigger asChild>
2284
+ <div className="group flex cursor-pointer flex-wrap items-center gap-2">
2285
+ <div className="flex min-w-0 items-center gap-2">
2286
+ <FileText className="h-4 w-4 text-amber-500" />
2287
+ <h3 className="text-sm font-semibold">
2288
+ {t('tabDocuments')}
2289
+ </h3>
2290
+ {documents.length > 0 ? (
2291
+ <Badge
2292
+ variant="secondary"
2293
+ className="bg-amber-500/10 text-amber-600"
2294
+ >
2295
+ {documents.length}
2296
+ </Badge>
2297
+ ) : null}
2298
+ </div>
2192
2299
 
2193
- <Input
2194
- ref={(element) => {
2195
- documentValueRefs.current[document.clientId] =
2196
- element;
2197
- }}
2198
- placeholder={t('documentValuePlaceholder')}
2199
- value={document.value}
2200
- onChange={(event) =>
2201
- updateDocument(document.clientId, {
2202
- value: maskDocumentValueByType(
2203
- event.target.value,
2204
- document.document_type_id
2205
- ),
2206
- })
2207
- }
2208
- className="h-8 flex-1 text-xs"
2209
- />
2300
+ <div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-1 sm:flex-nowrap">
2301
+ <Button
2302
+ type="button"
2303
+ variant="ghost"
2304
+ size="sm"
2305
+ className="h-7 shrink-0 px-2 text-xs"
2306
+ onClick={(event) => {
2307
+ event.stopPropagation();
2308
+ addDocument('cpf');
2309
+ }}
2310
+ >
2311
+ <Plus className="mr-1 h-3.5 w-3.5" />
2312
+ CPF
2313
+ </Button>
2314
+ <Button
2315
+ type="button"
2316
+ variant="ghost"
2317
+ size="sm"
2318
+ className="h-7 shrink-0 px-2 text-xs"
2319
+ onClick={(event) => {
2320
+ event.stopPropagation();
2321
+ addDocument('cnpj');
2322
+ }}
2323
+ >
2324
+ <Plus className="mr-1 h-3.5 w-3.5" />
2325
+ CNPJ
2326
+ </Button>
2327
+ <Button
2328
+ type="button"
2329
+ variant="ghost"
2330
+ size="sm"
2331
+ className="h-7 shrink-0 px-2 text-xs"
2332
+ onClick={(event) => {
2333
+ event.stopPropagation();
2334
+ addDocument('rg');
2335
+ }}
2336
+ >
2337
+ <Plus className="mr-1 h-3.5 w-3.5" />
2338
+ RG
2339
+ </Button>
2340
+ <Button
2341
+ type="button"
2342
+ variant="ghost"
2343
+ size="sm"
2344
+ className="h-7 shrink-0 px-2 text-xs"
2345
+ onClick={(event) => {
2346
+ event.stopPropagation();
2347
+ addDocument('blank');
2348
+ }}
2349
+ >
2350
+ <Plus className="mr-1 h-3.5 w-3.5" />
2351
+ <span className="max-w-20 truncate sm:max-w-none">
2352
+ {t('addDocument')}
2353
+ </span>
2354
+ </Button>
2355
+ {documentsOpen ? (
2356
+ <ChevronUp className="h-4 w-4" />
2357
+ ) : (
2358
+ <ChevronDown className="h-4 w-4" />
2359
+ )}
2360
+ </div>
2361
+ </div>
2362
+ </CollapsibleTrigger>
2210
2363
 
2211
- <Tooltip>
2212
- <TooltipTrigger asChild>
2213
- <Button
2214
- type="button"
2215
- variant="ghost"
2216
- size="icon"
2217
- className="h-8 w-8 text-red-500 hover:text-red-600"
2218
- onClick={() => removeDocument(document.clientId)}
2219
- aria-label={t('remove')}
2220
- >
2221
- <Trash2 className="h-4 w-4" />
2222
- </Button>
2223
- </TooltipTrigger>
2224
- <TooltipContent>{t('remove')}</TooltipContent>
2225
- </Tooltip>
2226
- </div>
2364
+ <CollapsibleContent className="mt-2 space-y-2">
2365
+ {documents.length === 0 ? (
2366
+ <div className="rounded-lg border-2 border-dashed py-4 text-center text-sm text-muted-foreground">
2367
+ {t('noDocuments')}
2227
2368
  </div>
2228
- ))
2229
- )}
2230
- </CollapsibleContent>
2231
- </Collapsible>
2232
- </form>
2233
- </div>
2369
+ ) : (
2370
+ documents.map((document) => (
2371
+ <div
2372
+ key={document.clientId}
2373
+ className="rounded-lg border p-2"
2374
+ >
2375
+ <div className="flex items-center gap-2">
2376
+ <Select
2377
+ value={
2378
+ document.document_type_id
2379
+ ? String(document.document_type_id)
2380
+ : undefined
2381
+ }
2382
+ onValueChange={(value) => {
2383
+ const nextTypeId = Number(value);
2384
+ updateDocument(document.clientId, {
2385
+ document_type_id: nextTypeId,
2386
+ value: maskDocumentValueByType(
2387
+ document.value || '',
2388
+ nextTypeId
2389
+ ),
2390
+ });
2391
+ }}
2392
+ >
2393
+ <SelectTrigger className="h-8 w-36 text-xs">
2394
+ <SelectValue
2395
+ placeholder={t('selectDocumentType')}
2396
+ />
2397
+ </SelectTrigger>
2398
+ <SelectContent>
2399
+ {documentTypes.map((documentType) => (
2400
+ <SelectItem
2401
+ key={documentType.document_type_id}
2402
+ value={String(documentType.document_type_id)}
2403
+ >
2404
+ {documentType.name}
2405
+ </SelectItem>
2406
+ ))}
2407
+ </SelectContent>
2408
+ </Select>
2409
+
2410
+ <Input
2411
+ ref={(element) => {
2412
+ documentValueRefs.current[document.clientId] =
2413
+ element;
2414
+ }}
2415
+ placeholder={t('documentValuePlaceholder')}
2416
+ value={document.value}
2417
+ onChange={(event) =>
2418
+ updateDocument(document.clientId, {
2419
+ value: maskDocumentValueByType(
2420
+ event.target.value,
2421
+ document.document_type_id
2422
+ ),
2423
+ })
2424
+ }
2425
+ className="h-8 flex-1 text-xs"
2426
+ />
2427
+
2428
+ <Tooltip>
2429
+ <TooltipTrigger asChild>
2430
+ <Button
2431
+ type="button"
2432
+ variant="ghost"
2433
+ size="icon"
2434
+ className="h-8 w-8 text-red-500 hover:text-red-600"
2435
+ onClick={() =>
2436
+ removeDocument(document.clientId)
2437
+ }
2438
+ aria-label={t('remove')}
2439
+ >
2440
+ <Trash2 className="h-4 w-4" />
2441
+ </Button>
2442
+ </TooltipTrigger>
2443
+ <TooltipContent>{t('remove')}</TooltipContent>
2444
+ </Tooltip>
2445
+ </div>
2446
+ </div>
2447
+ ))
2448
+ )}
2449
+ </CollapsibleContent>
2450
+ </Collapsible>
2451
+ </form>
2452
+ </div>
2234
2453
 
2235
- <div className="shrink-0 space-y-2 border-t p-4">
2236
- <div className="grid gap-2 sm:grid-cols-2">
2237
- {!isEditing ? (
2454
+ <div className="shrink-0 space-y-2 border-t p-4">
2455
+ <div className="grid gap-2 sm:grid-cols-2">
2456
+ {!isEditing ? (
2457
+ <Button
2458
+ type="button"
2459
+ variant="outline"
2460
+ onClick={() => {
2461
+ const formElement = document.getElementById(
2462
+ 'person-form'
2463
+ ) as HTMLFormElement | null;
2464
+ formElement?.requestSubmit();
2465
+ }}
2466
+ disabled={isSubmitting || isUploadingAvatar}
2467
+ >
2468
+ {t('saveAndNew')}
2469
+ </Button>
2470
+ ) : null}
2238
2471
  <Button
2239
- type="button"
2240
- variant="outline"
2241
- onClick={() => {
2242
- const formElement = document.getElementById(
2243
- 'person-form'
2244
- ) as HTMLFormElement | null;
2245
- formElement?.requestSubmit();
2246
- }}
2472
+ type="submit"
2473
+ form="person-form"
2474
+ className="w-full"
2247
2475
  disabled={isSubmitting || isUploadingAvatar}
2248
2476
  >
2249
- {t('saveAndNew')}
2477
+ {isSubmitting ? (
2478
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
2479
+ ) : (
2480
+ <Save className="mr-2 h-4 w-4" />
2481
+ )}
2482
+ {isSubmitting
2483
+ ? t('saving')
2484
+ : isEditing
2485
+ ? t('saveChanges')
2486
+ : t('createPerson')}
2250
2487
  </Button>
2251
- ) : null}
2488
+ </div>
2489
+ <p className="text-xs text-muted-foreground">
2490
+ {t('formShortcutsHint')}
2491
+ </p>
2492
+ </div>
2493
+ </SheetContent>
2494
+ </Sheet>
2495
+
2496
+ <AlertDialog
2497
+ open={duplicateDialogOpen}
2498
+ onOpenChange={(nextOpen: boolean) => {
2499
+ if (isResolvingDuplicateAction) return;
2500
+
2501
+ setDuplicateDialogOpen(nextOpen);
2502
+ if (!nextOpen) {
2503
+ setPendingDuplicateSubmission(null);
2504
+ setDuplicateTargetPersonId(null);
2505
+ }
2506
+ }}
2507
+ >
2508
+ <AlertDialogContent>
2509
+ <AlertDialogHeader>
2510
+ <AlertDialogTitle>{t('duplicateDialogTitle')}</AlertDialogTitle>
2511
+ <AlertDialogDescription>
2512
+ {t('duplicateDialogDescription')}
2513
+ </AlertDialogDescription>
2514
+ </AlertDialogHeader>
2515
+
2516
+ {pendingDuplicateSubmission ? (
2517
+ <div className="space-y-3">
2518
+ <div className="space-y-1.5">
2519
+ <Label className="text-xs font-medium">
2520
+ {t('duplicateTargetLabel')}
2521
+ </Label>
2522
+ <Select
2523
+ value={
2524
+ duplicateTargetPersonId
2525
+ ? String(duplicateTargetPersonId)
2526
+ : undefined
2527
+ }
2528
+ onValueChange={(value) =>
2529
+ setDuplicateTargetPersonId(Number(value))
2530
+ }
2531
+ >
2532
+ <SelectTrigger className="h-9 w-full">
2533
+ <SelectValue placeholder={t('duplicateSelectTarget')} />
2534
+ </SelectTrigger>
2535
+ <SelectContent>
2536
+ {pendingDuplicateSubmission.matches.map((match) => (
2537
+ <SelectItem key={match.id} value={String(match.id)}>
2538
+ #{match.id} · {match.name}
2539
+ </SelectItem>
2540
+ ))}
2541
+ </SelectContent>
2542
+ </Select>
2543
+ </div>
2544
+
2545
+ <div className="max-h-56 space-y-2 overflow-y-auto rounded-md border p-2">
2546
+ {pendingDuplicateSubmission.matches.map((match) => (
2547
+ <div
2548
+ key={match.id}
2549
+ className={cn(
2550
+ 'rounded-md border p-2',
2551
+ duplicateTargetPersonId === match.id
2552
+ ? 'border-primary/50 bg-primary/5'
2553
+ : 'border-border'
2554
+ )}
2555
+ >
2556
+ <div className="text-sm font-medium">
2557
+ #{match.id} · {match.name}
2558
+ </div>
2559
+ <div className="mt-1 flex flex-wrap gap-1.5">
2560
+ {match.reasons.map((reason) => (
2561
+ <Badge
2562
+ key={`${match.id}-${reason}`}
2563
+ variant="secondary"
2564
+ >
2565
+ {getDuplicateReasonLabel(reason)}
2566
+ </Badge>
2567
+ ))}
2568
+ </div>
2569
+ </div>
2570
+ ))}
2571
+ </div>
2572
+ </div>
2573
+ ) : null}
2574
+
2575
+ <AlertDialogFooter>
2576
+ <Button
2577
+ type="button"
2578
+ variant="outline"
2579
+ onClick={() => {
2580
+ setDuplicateDialogOpen(false);
2581
+ setPendingDuplicateSubmission(null);
2582
+ setDuplicateTargetPersonId(null);
2583
+ }}
2584
+ disabled={isResolvingDuplicateAction}
2585
+ >
2586
+ {t('cancel')}
2587
+ </Button>
2252
2588
  <Button
2253
- type="submit"
2254
- form="person-form"
2255
- className="w-full"
2256
- disabled={isSubmitting || isUploadingAvatar}
2589
+ type="button"
2590
+ variant="outline"
2591
+ onClick={() => void handleContinueWithDuplicate()}
2592
+ disabled={isResolvingDuplicateAction}
2257
2593
  >
2258
- {isSubmitting ? (
2594
+ {isResolvingDuplicateAction ? (
2259
2595
  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
2260
- ) : (
2261
- <Save className="mr-2 h-4 w-4" />
2262
- )}
2263
- {isSubmitting
2264
- ? t('saving')
2265
- : isEditing
2266
- ? t('saveChanges')
2267
- : t('createPerson')}
2596
+ ) : null}
2597
+ {t('duplicateContinueAction')}
2268
2598
  </Button>
2269
- </div>
2270
- <p className="text-xs text-muted-foreground">
2271
- {t('formShortcutsHint')}
2272
- </p>
2273
- </div>
2274
- </SheetContent>
2275
- </Sheet>
2599
+ <Button
2600
+ type="button"
2601
+ onClick={() => void handleMergeWithDuplicate()}
2602
+ disabled={isResolvingDuplicateAction || !duplicateTargetPersonId}
2603
+ >
2604
+ {isResolvingDuplicateAction ? (
2605
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
2606
+ ) : null}
2607
+ {t('duplicateMergeAction')}
2608
+ </Button>
2609
+ </AlertDialogFooter>
2610
+ </AlertDialogContent>
2611
+ </AlertDialog>
2612
+ </>
2276
2613
  );
2277
2614
  }