@traky-app/ui 1.6.1 → 1.6.4

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 (66) hide show
  1. package/package.json +15 -1
  2. package/registry/traky/json/city-select.json +19 -0
  3. package/registry/traky/json/country-select.json +24 -0
  4. package/registry/traky/json/geo-search-select.json +24 -0
  5. package/registry/traky/json/geo-selectors.json +21 -0
  6. package/registry/traky/json/google-maps-address-picker.json +22 -0
  7. package/registry/traky/json/internal-chat-mention.json +1 -1
  8. package/registry/traky/json/internal-chat-panel.json +1 -1
  9. package/registry/traky/json/lib-geo-city-codes.json +14 -0
  10. package/registry/traky/json/lib-geo-countries.json +14 -0
  11. package/registry/traky/json/lib-geo-csc.json +17 -0
  12. package/registry/traky/json/lib-geo-locations.json +14 -0
  13. package/registry/traky/json/lib-geo-overrides.json +14 -0
  14. package/registry/traky/json/lib-geo-types.json +14 -0
  15. package/registry/traky/json/lib-google-maps-address.json +14 -0
  16. package/registry/traky/json/lib-phone-countries.json +14 -0
  17. package/registry/traky/json/lib-phone-e164.json +14 -0
  18. package/registry/traky/json/lib-utils.json +1 -1
  19. package/registry/traky/json/office-address-form-dialog.json +17 -0
  20. package/registry/traky/json/office-address-form.json +31 -0
  21. package/registry/traky/json/phone-number-input.json +25 -0
  22. package/registry/traky/json/state-select.json +19 -0
  23. package/registry/traky/lib/geo-city-codes.ts +234 -0
  24. package/registry/traky/lib/geo-countries.ts +75 -0
  25. package/registry/traky/lib/geo-csc.ts +82 -0
  26. package/registry/traky/lib/geo-locations.ts +171 -0
  27. package/registry/traky/lib/geo-overrides.ts +375 -0
  28. package/registry/traky/lib/geo-types.ts +34 -0
  29. package/registry/traky/lib/google-maps-address.ts +100 -0
  30. package/registry/traky/lib/phone-countries.ts +380 -0
  31. package/registry/traky/lib/phone-e164.ts +66 -0
  32. package/registry/traky/lib/utils.ts +24 -0
  33. package/registry/traky/registry.json +180 -0
  34. package/registry/traky/ui/city-select.tsx +113 -0
  35. package/registry/traky/ui/country-select.tsx +233 -0
  36. package/registry/traky/ui/geo-search-select.tsx +136 -0
  37. package/registry/traky/ui/geo-selectors.tsx +162 -0
  38. package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
  39. package/registry/traky/ui/internal-chat-mention.ts +34 -3
  40. package/registry/traky/ui/internal-chat-panel.tsx +147 -12
  41. package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
  42. package/registry/traky/ui/office-address-form.tsx +868 -0
  43. package/registry/traky/ui/phone-number-input.tsx +253 -0
  44. package/registry/traky/ui/state-select.tsx +107 -0
  45. package/src/lib/geo-city-codes.ts +234 -0
  46. package/src/lib/geo-countries.ts +75 -0
  47. package/src/lib/geo-csc.ts +82 -0
  48. package/src/lib/geo-locations.ts +171 -0
  49. package/src/lib/geo-overrides.ts +375 -0
  50. package/src/lib/geo-types.ts +34 -0
  51. package/src/lib/google-maps-address.ts +100 -0
  52. package/src/lib/phone-countries.ts +380 -0
  53. package/src/lib/phone-e164.ts +66 -0
  54. package/src/test/setup.ts +31 -0
  55. package/src/ui/__tests__/geo-selects.test.tsx +504 -0
  56. package/src/ui/city-select.tsx +113 -0
  57. package/src/ui/country-select.tsx +233 -0
  58. package/src/ui/geo-search-select.tsx +136 -0
  59. package/src/ui/geo-selectors.tsx +162 -0
  60. package/src/ui/google-maps-address-picker.tsx +422 -0
  61. package/src/ui/internal-chat-mention.ts +34 -3
  62. package/src/ui/internal-chat-panel.tsx +147 -12
  63. package/src/ui/office-address-form-dialog.tsx +16 -0
  64. package/src/ui/office-address-form.tsx +877 -0
  65. package/src/ui/phone-number-input.tsx +253 -0
  66. package/src/ui/state-select.tsx +107 -0
@@ -0,0 +1,877 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { IconEdit, IconMapPin, IconPlus } from "@tabler/icons-react"
5
+ import type { Icon } from "@tabler/icons-react"
6
+
7
+ import { cn } from "../lib/utils"
8
+ import {
9
+ AlertDialog,
10
+ AlertDialogAction,
11
+ AlertDialogCancel,
12
+ AlertDialogContent,
13
+ AlertDialogTrigger,
14
+ } from "./alert-dialog"
15
+ import { Button } from "./button"
16
+ import { Spinner } from "./spinner"
17
+ import { googleMapsSelectionToFormPatch } from "../lib/google-maps-address"
18
+ import {
19
+ Card,
20
+ CardContent,
21
+ CardDescription,
22
+ CardFooter,
23
+ CardHeader,
24
+ CardTitle,
25
+ } from "./card"
26
+ import { EntityIcon } from "./entity-icon"
27
+ import { GoogleMapsAddressPicker } from "./google-maps-address-picker"
28
+ import { Input } from "./input"
29
+ import { Label } from "./label"
30
+ import {
31
+ Select,
32
+ SelectContent,
33
+ SelectGroup,
34
+ SelectItem,
35
+ SelectTrigger,
36
+ SelectValue,
37
+ } from "./select"
38
+ import { Switch } from "./switch"
39
+ import { Textarea } from "./textarea"
40
+
41
+ export type OfficeAddressEntryMode = "manual" | "gps"
42
+
43
+ function OfficeAddressEntryModeSwitch({
44
+ mode,
45
+ onModeChange,
46
+ disabled = false,
47
+ className,
48
+ }: {
49
+ mode: OfficeAddressEntryMode
50
+ onModeChange: (mode: OfficeAddressEntryMode) => void
51
+ disabled?: boolean
52
+ className?: string
53
+ }) {
54
+ const isGps = mode === "gps"
55
+
56
+ return (
57
+ <div
58
+ className={cn(
59
+ "flex shrink-0 items-center gap-2 text-xs text-muted-foreground",
60
+ className,
61
+ )}
62
+ role="group"
63
+ aria-label="Modo de ingreso de dirección"
64
+ >
65
+ <span className={cn(!isGps && "font-semibold text-foreground")}>
66
+ Manual
67
+ </span>
68
+ <Switch
69
+ checked={isGps}
70
+ disabled={disabled}
71
+ aria-label={isGps ? "Modo GPS" : "Modo manual"}
72
+ onCheckedChange={(checked) =>
73
+ onModeChange(checked ? "gps" : "manual")
74
+ }
75
+ />
76
+ <span className={cn(isGps && "font-semibold text-foreground")}>GPS</span>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ /** `customer` = dirección de envío (contacto/portal). `enterprise` = sucursal o bodega. */
82
+ export type OfficeAddressFormVariant = "customer" | "enterprise"
83
+
84
+ const OFFICE_ADDRESS_COUNTRY_OPTIONS: { code: string; label: string }[] = [
85
+ { code: "EC", label: "Ecuador" },
86
+ { code: "US", label: "Estados Unidos" },
87
+ { code: "MX", label: "México" },
88
+ { code: "CO", label: "Colombia" },
89
+ { code: "PE", label: "Perú" },
90
+ { code: "CL", label: "Chile" },
91
+ { code: "PA", label: "Panamá" },
92
+ { code: "CR", label: "Costa Rica" },
93
+ ]
94
+
95
+ const FAX_COUNTRY_CODES = new Set(["US", "CA", "PR"])
96
+
97
+ export function isOfficeAddressFaxCountry(countryCode: string | undefined) {
98
+ return FAX_COUNTRY_CODES.has(countryCode?.trim().toUpperCase() ?? "")
99
+ }
100
+
101
+ export type OfficeAddressFormValues = {
102
+ countryCode: string
103
+ address: string
104
+ suiteOrApt: string
105
+ city: string
106
+ stateOrProvince: string
107
+ postalCode: string
108
+ phoneNumber: string
109
+ fax: string
110
+ note: string
111
+ isActive: boolean
112
+ isMain: boolean
113
+ isWarehouseOnly: boolean
114
+ latitude: number | null
115
+ longitude: number | null
116
+ addressGeocodeSource: string | null
117
+ }
118
+
119
+ export const EMPTY_OFFICE_ADDRESS_FORM_VALUES: OfficeAddressFormValues = {
120
+ countryCode: "",
121
+ address: "",
122
+ suiteOrApt: "",
123
+ city: "",
124
+ stateOrProvince: "",
125
+ postalCode: "",
126
+ phoneNumber: "",
127
+ fax: "",
128
+ note: "",
129
+ isActive: true,
130
+ isMain: false,
131
+ isWarehouseOnly: false,
132
+ latitude: null,
133
+ longitude: null,
134
+ addressGeocodeSource: null,
135
+ }
136
+
137
+ export function mergeOfficeAddressFormValues(
138
+ initial?: Partial<OfficeAddressFormValues>,
139
+ ): OfficeAddressFormValues {
140
+ return {
141
+ ...EMPTY_OFFICE_ADDRESS_FORM_VALUES,
142
+ ...initial,
143
+ isActive: initial?.isActive ?? true,
144
+ isMain: initial?.isMain ?? false,
145
+ }
146
+ }
147
+
148
+ export function resolveOfficeAddressEntryMode(
149
+ initial?: Partial<OfficeAddressFormValues>,
150
+ initialEntryMode?: OfficeAddressEntryMode,
151
+ ): OfficeAddressEntryMode {
152
+ if (initialEntryMode) return initialEntryMode
153
+ return initial?.addressGeocodeSource === "google_maps" ? "gps" : "manual"
154
+ }
155
+
156
+ export function useOfficeAddressForm(options?: {
157
+ initialValues?: Partial<OfficeAddressFormValues>
158
+ initialEntryMode?: OfficeAddressEntryMode
159
+ }) {
160
+ const [form, setForm] = React.useState<OfficeAddressFormValues>(() =>
161
+ mergeOfficeAddressFormValues(options?.initialValues),
162
+ )
163
+ const [addressEntryMode, setAddressEntryMode] =
164
+ React.useState<OfficeAddressEntryMode>(() =>
165
+ resolveOfficeAddressEntryMode(
166
+ options?.initialValues,
167
+ options?.initialEntryMode,
168
+ ),
169
+ )
170
+
171
+ const reset = React.useCallback(
172
+ (
173
+ nextInitial?: Partial<OfficeAddressFormValues>,
174
+ nextMode?: OfficeAddressEntryMode,
175
+ ) => {
176
+ setForm(mergeOfficeAddressFormValues(nextInitial))
177
+ setAddressEntryMode(
178
+ resolveOfficeAddressEntryMode(nextInitial, nextMode),
179
+ )
180
+ },
181
+ [],
182
+ )
183
+
184
+ const setAddressEntryModeAndClearGps = React.useCallback(
185
+ (mode: OfficeAddressEntryMode) => {
186
+ setAddressEntryMode(mode)
187
+ if (mode === "manual") {
188
+ setForm((prev) => ({
189
+ ...prev,
190
+ latitude: null,
191
+ longitude: null,
192
+ addressGeocodeSource: null,
193
+ }))
194
+ }
195
+ },
196
+ [],
197
+ )
198
+
199
+ return {
200
+ form,
201
+ setForm,
202
+ addressEntryMode,
203
+ setAddressEntryMode: setAddressEntryModeAndClearGps,
204
+ reset,
205
+ }
206
+ }
207
+
208
+ export type OfficeAddressFormCardProps = {
209
+ title: string
210
+ description?: string
211
+ icon?: Icon
212
+ form: OfficeAddressFormValues
213
+ onFormChange: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>
214
+ addressEntryMode: OfficeAddressEntryMode
215
+ onAddressEntryModeChange: (mode: OfficeAddressEntryMode) => void
216
+ disabled?: boolean
217
+ regionCode?: string
218
+ googleMapsApiKey?: string
219
+ /** Estable para remontar GoogleMaps al editar. */
220
+ pickerKey?: string
221
+ footer?: React.ReactNode
222
+ className?: string
223
+ /**
224
+ * `dialog`: card plana dentro de AlertDialog (sin borde/sombra exterior).
225
+ * `page`: card completa para rutas de edición.
226
+ */
227
+ layout?: "dialog" | "page"
228
+ contentClassName?: string
229
+ /** customer = envío; enterprise = sucursal/bodega (campos extra). */
230
+ variant?: OfficeAddressFormVariant
231
+ /** Reemplaza el selector de país por defecto (p. ej. CountrySelect en la app). */
232
+ countryField?: React.ReactNode
233
+ phoneField?: React.ReactNode
234
+ faxField?: React.ReactNode
235
+ manualFields?: React.ReactNode
236
+ }
237
+
238
+ export function OfficeAddressFormCard({
239
+ title,
240
+ description,
241
+ icon: HeaderIcon = IconMapPin,
242
+ form,
243
+ onFormChange,
244
+ addressEntryMode,
245
+ onAddressEntryModeChange,
246
+ disabled = false,
247
+ regionCode,
248
+ googleMapsApiKey,
249
+ pickerKey = "office-address-gps",
250
+ footer,
251
+ className,
252
+ layout = "page",
253
+ contentClassName,
254
+ variant = "customer",
255
+ countryField,
256
+ phoneField,
257
+ faxField,
258
+ manualFields,
259
+ }: OfficeAddressFormCardProps) {
260
+ const busy = disabled
261
+ const isCustomer = variant === "customer"
262
+ const mapRegion =
263
+ form.countryCode?.trim().toUpperCase() ||
264
+ regionCode?.trim().toUpperCase() ||
265
+ undefined
266
+ const showFax =
267
+ Boolean(faxField) ||
268
+ isOfficeAddressFaxCountry(form.countryCode) ||
269
+ form.fax.trim().length > 0
270
+
271
+ const handleChange = (
272
+ e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
273
+ ) => {
274
+ const { name, value } = e.target
275
+ onFormChange((prev) => ({ ...prev, [name]: value }))
276
+ }
277
+
278
+ const embedded = layout === "dialog"
279
+
280
+ const countryControl =
281
+ countryField ?? (
282
+ <Select
283
+ value={form.countryCode || undefined}
284
+ disabled={busy}
285
+ onValueChange={(code) =>
286
+ onFormChange((prev) => ({ ...prev, countryCode: code }))
287
+ }
288
+ >
289
+ <SelectTrigger className="w-full" aria-label="País">
290
+ <SelectValue placeholder="Selecciona el país" />
291
+ </SelectTrigger>
292
+ <SelectContent>
293
+ <SelectGroup>
294
+ {OFFICE_ADDRESS_COUNTRY_OPTIONS.map((c) => (
295
+ <SelectItem key={c.code} value={c.code}>
296
+ {c.label}
297
+ </SelectItem>
298
+ ))}
299
+ </SelectGroup>
300
+ </SelectContent>
301
+ </Select>
302
+ )
303
+
304
+ return (
305
+ <Card
306
+ className={cn(
307
+ "gap-0 py-0",
308
+ embedded
309
+ ? "border-0 bg-transparent shadow-none"
310
+ : "border-border shadow-sm",
311
+ className,
312
+ )}
313
+ >
314
+ <CardHeader
315
+ className={cn(
316
+ "flex flex-row flex-wrap items-center justify-between gap-3 space-y-0 px-5 pt-5 pb-3.5 sm:px-6",
317
+ embedded && "border-b border-border/60",
318
+ !embedded && "border-b border-border/60",
319
+ )}
320
+ >
321
+ <div className="flex min-w-0 flex-1 items-start gap-3">
322
+ <EntityIcon icon={HeaderIcon} size="sm" className="mb-0" />
323
+ <div className="min-w-0 space-y-0.5">
324
+ <CardTitle className="text-sm font-semibold tracking-tight">
325
+ {title}
326
+ </CardTitle>
327
+ {description ? (
328
+ <CardDescription className="text-xs">{description}</CardDescription>
329
+ ) : null}
330
+ </div>
331
+ </div>
332
+ <OfficeAddressEntryModeSwitch
333
+ mode={addressEntryMode}
334
+ disabled={busy}
335
+ onModeChange={onAddressEntryModeChange}
336
+ />
337
+ </CardHeader>
338
+
339
+ <CardContent
340
+ className={cn(
341
+ "grid min-h-0 grid-cols-1 gap-4 px-5 py-4 sm:grid-cols-2 sm:px-6",
342
+ embedded && "max-h-[min(60dvh,28rem)] overflow-y-auto overscroll-contain",
343
+ contentClassName,
344
+ )}
345
+ >
346
+ {addressEntryMode === "gps" ? (
347
+ <div className="col-span-1 sm:col-span-2">
348
+ <GoogleMapsAddressPicker
349
+ key={pickerKey}
350
+ apiKey={googleMapsApiKey}
351
+ regionCode={mapRegion}
352
+ initialLatitude={form.latitude ?? undefined}
353
+ initialLongitude={form.longitude ?? undefined}
354
+ initialFormattedAddress={form.address}
355
+ onLocationSelect={(selection) => {
356
+ const patch = googleMapsSelectionToFormPatch(selection)
357
+ onFormChange((prev) => ({
358
+ ...prev,
359
+ address: patch.address,
360
+ city: patch.city,
361
+ stateOrProvince: patch.stateOrProvince,
362
+ postalCode: patch.postalCode,
363
+ countryCode: patch.countryCode || prev.countryCode,
364
+ latitude: patch.latitude,
365
+ longitude: patch.longitude,
366
+ addressGeocodeSource: patch.addressGeocodeSource,
367
+ }))
368
+ }}
369
+ onLocationClear={() => {
370
+ onFormChange((prev) => ({
371
+ ...prev,
372
+ address: "",
373
+ city: "",
374
+ stateOrProvince: "",
375
+ postalCode: "",
376
+ latitude: null,
377
+ longitude: null,
378
+ addressGeocodeSource: null,
379
+ }))
380
+ }}
381
+ />
382
+ </div>
383
+ ) : null}
384
+
385
+ <div className="col-span-1 space-y-1 sm:col-span-2">
386
+ <Label htmlFor="office-country">País</Label>
387
+ {countryControl}
388
+ </div>
389
+
390
+ {addressEntryMode === "manual"
391
+ ? (manualFields ?? (
392
+ <>
393
+ <div className="col-span-1 space-y-1 sm:col-span-2">
394
+ <Label htmlFor="office-address">Dirección</Label>
395
+ <Input
396
+ id="office-address"
397
+ name="address"
398
+ value={form.address}
399
+ onChange={handleChange}
400
+ placeholder="Calle y número"
401
+ disabled={busy}
402
+ />
403
+ </div>
404
+ <div className="space-y-1">
405
+ <Label htmlFor="office-city">Ciudad</Label>
406
+ <Input
407
+ id="office-city"
408
+ name="city"
409
+ value={form.city}
410
+ onChange={handleChange}
411
+ disabled={busy}
412
+ />
413
+ </div>
414
+ <div className="space-y-1">
415
+ <Label htmlFor="office-state">Estado / Provincia</Label>
416
+ <Input
417
+ id="office-state"
418
+ name="stateOrProvince"
419
+ value={form.stateOrProvince}
420
+ onChange={handleChange}
421
+ disabled={busy}
422
+ />
423
+ </div>
424
+ <div className="space-y-1">
425
+ <Label htmlFor="office-postal">Código postal</Label>
426
+ <Input
427
+ id="office-postal"
428
+ name="postalCode"
429
+ value={form.postalCode}
430
+ onChange={handleChange}
431
+ disabled={busy}
432
+ />
433
+ </div>
434
+ </>
435
+ ))
436
+ : null}
437
+
438
+ {addressEntryMode === "gps" && form.address.trim() ? (
439
+ <div className="col-span-1 rounded-lg border border-border bg-muted/30 px-3 py-2.5 text-sm sm:col-span-2">
440
+ <p className="font-medium text-foreground">{form.address}</p>
441
+ <p className="text-muted-foreground">
442
+ {[form.city, form.stateOrProvince, form.postalCode, form.countryCode]
443
+ .filter(Boolean)
444
+ .join(" · ")}
445
+ </p>
446
+ </div>
447
+ ) : null}
448
+
449
+ <div className="col-span-1 space-y-1 sm:col-span-2">
450
+ <Label htmlFor="office-suite">Suite / apt</Label>
451
+ <Input
452
+ id="office-suite"
453
+ name="suiteOrApt"
454
+ value={form.suiteOrApt}
455
+ onChange={handleChange}
456
+ disabled={busy}
457
+ />
458
+ </div>
459
+
460
+ {phoneField ?? (
461
+ <div className="space-y-1">
462
+ <Label htmlFor="office-phone">Teléfono</Label>
463
+ <Input
464
+ id="office-phone"
465
+ name="phoneNumber"
466
+ type="tel"
467
+ value={form.phoneNumber}
468
+ onChange={handleChange}
469
+ placeholder="+593 …"
470
+ disabled={busy}
471
+ />
472
+ </div>
473
+ )}
474
+
475
+ {showFax
476
+ ? (faxField ?? (
477
+ <div className="space-y-1">
478
+ <Label htmlFor="office-fax">Fax</Label>
479
+ <Input
480
+ id="office-fax"
481
+ name="fax"
482
+ type="tel"
483
+ value={form.fax}
484
+ onChange={handleChange}
485
+ disabled={busy}
486
+ />
487
+ </div>
488
+ ))
489
+ : null}
490
+
491
+ <div className="col-span-1 space-y-1 sm:col-span-2">
492
+ <Label htmlFor="office-note">Nota</Label>
493
+ <Textarea
494
+ id="office-note"
495
+ name="note"
496
+ value={form.note}
497
+ onChange={handleChange}
498
+ placeholder="Información adicional sobre esta dirección"
499
+ className="min-h-20 resize-y"
500
+ disabled={busy}
501
+ />
502
+ </div>
503
+
504
+ <div className="space-y-1">
505
+ <Label htmlFor="office-active">Estado</Label>
506
+ <Select
507
+ value={form.isActive.toString()}
508
+ disabled={busy}
509
+ onValueChange={(value) =>
510
+ onFormChange((prev) => ({
511
+ ...prev,
512
+ isActive: value === "true",
513
+ }))
514
+ }
515
+ >
516
+ <SelectTrigger id="office-active" className="w-full">
517
+ <SelectValue placeholder="Seleccione el estado" />
518
+ </SelectTrigger>
519
+ <SelectContent>
520
+ <SelectGroup>
521
+ <SelectItem value="true">Activo</SelectItem>
522
+ <SelectItem value="false">Inactivo</SelectItem>
523
+ </SelectGroup>
524
+ </SelectContent>
525
+ </Select>
526
+ </div>
527
+
528
+ {!isCustomer ? (
529
+ <>
530
+ <div className="col-span-1 space-y-1 sm:col-span-2">
531
+ <Label>Tipo de ubicación</Label>
532
+ <Select
533
+ value={form.isWarehouseOnly ? "warehouse" : "agency"}
534
+ disabled={busy}
535
+ onValueChange={(value) => {
536
+ const isWarehouseOnly = value === "warehouse"
537
+ onFormChange((prev) => ({
538
+ ...prev,
539
+ isWarehouseOnly,
540
+ isMain: isWarehouseOnly ? false : prev.isMain,
541
+ }))
542
+ }}
543
+ >
544
+ <SelectTrigger className="w-full">
545
+ <SelectValue placeholder="Selecciona el tipo" />
546
+ </SelectTrigger>
547
+ <SelectContent>
548
+ <SelectGroup>
549
+ <SelectItem value="agency">Es sucursal</SelectItem>
550
+ <SelectItem value="warehouse">Es solo bodega</SelectItem>
551
+ </SelectGroup>
552
+ </SelectContent>
553
+ </Select>
554
+ </div>
555
+ <div className="space-y-1">
556
+ <Label>Dirección principal</Label>
557
+ <Select
558
+ value={form.isMain.toString()}
559
+ disabled={busy || form.isWarehouseOnly}
560
+ onValueChange={(value) =>
561
+ onFormChange((prev) => ({
562
+ ...prev,
563
+ isMain: value === "true",
564
+ }))
565
+ }
566
+ >
567
+ <SelectTrigger className="w-full">
568
+ <SelectValue placeholder="¿Es dirección principal?" />
569
+ </SelectTrigger>
570
+ <SelectContent>
571
+ <SelectGroup>
572
+ <SelectItem value="true">Sí</SelectItem>
573
+ <SelectItem value="false">No</SelectItem>
574
+ </SelectGroup>
575
+ </SelectContent>
576
+ </Select>
577
+ </div>
578
+ </>
579
+ ) : null}
580
+ </CardContent>
581
+
582
+ {footer ? (
583
+ <CardFooter
584
+ className={cn(
585
+ "border-t border-border/60 px-5 py-3 sm:px-6",
586
+ embedded && "bg-background",
587
+ )}
588
+ >
589
+ {footer}
590
+ </CardFooter>
591
+ ) : null}
592
+ </Card>
593
+ )
594
+ }
595
+
596
+ type OfficeAddressFormSharedProps = {
597
+ title?: string
598
+ titleCreate?: string
599
+ titleEdit?: string
600
+ description?: string
601
+ initialValues?: Partial<OfficeAddressFormValues>
602
+ initialEntryMode?: OfficeAddressEntryMode
603
+ regionCode?: string
604
+ googleMapsApiKey?: string
605
+ pickerKey?: string
606
+ className?: string
607
+ contentClassName?: string
608
+ icon?: Icon
609
+ disabled?: boolean
610
+ variant?: OfficeAddressFormVariant
611
+ countryField?: React.ReactNode
612
+ phoneField?: React.ReactNode
613
+ faxField?: React.ReactNode
614
+ manualFields?: React.ReactNode
615
+ }
616
+
617
+ type OfficeAddressFormInlineProps = OfficeAddressFormSharedProps & {
618
+ /** Card en página o sección (por defecto). */
619
+ surface?: "inline"
620
+ form?: OfficeAddressFormValues
621
+ onFormChange?: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>
622
+ addressEntryMode?: OfficeAddressEntryMode
623
+ onAddressEntryModeChange?: (mode: OfficeAddressEntryMode) => void
624
+ footer?: React.ReactNode
625
+ }
626
+
627
+ type OfficeAddressFormModalProps = OfficeAddressFormSharedProps & {
628
+ /** Misma card dentro de AlertDialog (contactos, selectores, etc.). */
629
+ surface: "modal"
630
+ type?: "create" | "edit"
631
+ open?: boolean
632
+ defaultOpen?: boolean
633
+ onOpenChange?: (open: boolean) => void
634
+ onSubmit?: (values: OfficeAddressFormValues) => void | Promise<void>
635
+ trigger?: React.ReactNode
636
+ hideTrigger?: boolean
637
+ loading?: boolean
638
+ dialogClassName?: string
639
+ form?: OfficeAddressFormValues
640
+ onFormChange?: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>
641
+ addressEntryMode?: OfficeAddressEntryMode
642
+ onAddressEntryModeChange?: (mode: OfficeAddressEntryMode) => void
643
+ }
644
+
645
+ export type OfficeAddressFormProps =
646
+ | OfficeAddressFormInlineProps
647
+ | OfficeAddressFormModalProps
648
+
649
+ /**
650
+ * Formulario de dirección unificado: card en página (`surface="inline"`) o en modal (`surface="modal"`).
651
+ * Usa `OfficeAddressFormCard` + `useOfficeAddressForm` por debajo.
652
+ */
653
+ export function OfficeAddressForm(props: OfficeAddressFormProps) {
654
+ if (props.surface === "modal") {
655
+ return <OfficeAddressFormModal {...props} />
656
+ }
657
+ return <OfficeAddressFormInline {...props} />
658
+ }
659
+
660
+ function OfficeAddressFormInline({
661
+ title = "Dirección",
662
+ description,
663
+ initialValues,
664
+ initialEntryMode,
665
+ form: controlledForm,
666
+ onFormChange: controlledOnFormChange,
667
+ addressEntryMode: controlledMode,
668
+ onAddressEntryModeChange: controlledOnModeChange,
669
+ footer,
670
+ regionCode,
671
+ googleMapsApiKey,
672
+ pickerKey = "office-address-inline",
673
+ className,
674
+ contentClassName,
675
+ icon,
676
+ disabled,
677
+ variant,
678
+ countryField,
679
+ phoneField,
680
+ faxField,
681
+ manualFields,
682
+ }: OfficeAddressFormInlineProps) {
683
+ const internal = useOfficeAddressForm({ initialValues, initialEntryMode })
684
+ const form = controlledForm ?? internal.form
685
+ const setForm = controlledOnFormChange ?? internal.setForm
686
+ const addressEntryMode = controlledMode ?? internal.addressEntryMode
687
+ const setAddressEntryMode =
688
+ controlledOnModeChange ?? internal.setAddressEntryMode
689
+
690
+ return (
691
+ <OfficeAddressFormCard
692
+ layout="page"
693
+ title={title}
694
+ description={description}
695
+ icon={icon}
696
+ form={form}
697
+ onFormChange={setForm}
698
+ addressEntryMode={addressEntryMode}
699
+ onAddressEntryModeChange={setAddressEntryMode}
700
+ disabled={disabled}
701
+ regionCode={regionCode}
702
+ googleMapsApiKey={googleMapsApiKey}
703
+ pickerKey={pickerKey}
704
+ footer={footer}
705
+ className={className}
706
+ contentClassName={contentClassName}
707
+ variant={variant}
708
+ countryField={countryField}
709
+ phoneField={phoneField}
710
+ faxField={faxField}
711
+ manualFields={manualFields}
712
+ />
713
+ )
714
+ }
715
+
716
+ function OfficeAddressFormModal({
717
+ type = "create",
718
+ open: controlledOpen,
719
+ defaultOpen = false,
720
+ onOpenChange,
721
+ initialValues,
722
+ initialEntryMode,
723
+ onSubmit,
724
+ trigger,
725
+ hideTrigger = false,
726
+ loading = false,
727
+ titleCreate = "Crear dirección",
728
+ titleEdit = "Editar dirección",
729
+ description,
730
+ regionCode,
731
+ googleMapsApiKey,
732
+ className,
733
+ dialogClassName,
734
+ variant,
735
+ countryField,
736
+ phoneField,
737
+ faxField,
738
+ manualFields,
739
+ icon,
740
+ form: controlledForm,
741
+ onFormChange: controlledOnFormChange,
742
+ addressEntryMode: controlledMode,
743
+ onAddressEntryModeChange: controlledOnModeChange,
744
+ }: OfficeAddressFormModalProps) {
745
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
746
+ const isControlled = controlledOpen !== undefined
747
+ const open = isControlled ? Boolean(controlledOpen) : uncontrolledOpen
748
+
749
+ const internal = useOfficeAddressForm({ initialValues, initialEntryMode })
750
+ const form = controlledForm ?? internal.form
751
+ const setForm = controlledOnFormChange ?? internal.setForm
752
+ const addressEntryMode = controlledMode ?? internal.addressEntryMode
753
+ const setAddressEntryMode =
754
+ controlledOnModeChange ?? internal.setAddressEntryMode
755
+ const reset = internal.reset
756
+
757
+ const [submitting, setSubmitting] = React.useState(false)
758
+
759
+ const setOpen = (next: boolean) => {
760
+ if (!isControlled) setUncontrolledOpen(next)
761
+ onOpenChange?.(next)
762
+ }
763
+
764
+ React.useEffect(() => {
765
+ if (!open) return
766
+ reset(initialValues, initialEntryMode)
767
+ }, [open, initialValues, initialEntryMode, reset])
768
+
769
+ const modalTitle = type === "edit" ? titleEdit : titleCreate
770
+ const busy = loading || submitting
771
+
772
+ const handleSubmit = async () => {
773
+ setSubmitting(true)
774
+ try {
775
+ await onSubmit?.(form)
776
+ setOpen(false)
777
+ } finally {
778
+ setSubmitting(false)
779
+ }
780
+ }
781
+
782
+ const pickerKey =
783
+ type === "edit" && initialValues
784
+ ? `gps-modal-${String((initialValues as { id?: string }).id ?? "edit")}`
785
+ : "gps-modal-create"
786
+
787
+ return (
788
+ <AlertDialog open={open} onOpenChange={setOpen}>
789
+ {!hideTrigger ? (
790
+ <AlertDialogTrigger asChild>
791
+ {trigger ??
792
+ (type === "edit" ? (
793
+ <Button
794
+ type="button"
795
+ size="sm"
796
+ variant="outline"
797
+ disabled={busy}
798
+ aria-label="Editar dirección"
799
+ className={className}
800
+ >
801
+ {busy ? <Spinner className="size-4" /> : <IconEdit size={16} />}
802
+ </Button>
803
+ ) : (
804
+ <Button type="button" disabled={busy} className={className}>
805
+ {busy ? <Spinner /> : <IconPlus />}
806
+ Nueva dirección
807
+ </Button>
808
+ ))}
809
+ </AlertDialogTrigger>
810
+ ) : null}
811
+
812
+ <AlertDialogContent
813
+ className={cn(
814
+ "flex max-h-[min(90dvh,48rem)] w-[calc(100%-1.5rem)] max-w-2xl flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
815
+ dialogClassName,
816
+ )}
817
+ >
818
+ <OfficeAddressFormCard
819
+ layout="dialog"
820
+ title={modalTitle}
821
+ description={description}
822
+ icon={icon}
823
+ variant={variant}
824
+ countryField={countryField}
825
+ phoneField={phoneField}
826
+ faxField={faxField}
827
+ manualFields={manualFields}
828
+ form={form}
829
+ onFormChange={setForm}
830
+ addressEntryMode={addressEntryMode}
831
+ onAddressEntryModeChange={setAddressEntryMode}
832
+ disabled={busy}
833
+ regionCode={regionCode}
834
+ googleMapsApiKey={googleMapsApiKey}
835
+ pickerKey={pickerKey}
836
+ footer={
837
+ <div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
838
+ <AlertDialogCancel type="button" disabled={busy} className="mt-0">
839
+ Cancelar
840
+ </AlertDialogCancel>
841
+ <AlertDialogAction
842
+ type="button"
843
+ disabled={busy}
844
+ className="mt-0"
845
+ onClick={(e) => {
846
+ e.preventDefault()
847
+ void handleSubmit()
848
+ }}
849
+ >
850
+ {busy ? (
851
+ <>
852
+ <Spinner className="mr-2 size-4" />
853
+ Guardando…
854
+ </>
855
+ ) : type === "edit" ? (
856
+ "Guardar cambios"
857
+ ) : (
858
+ "Crear dirección"
859
+ )}
860
+ </AlertDialogAction>
861
+ </div>
862
+ }
863
+ />
864
+ </AlertDialogContent>
865
+ </AlertDialog>
866
+ )
867
+ }
868
+
869
+ /** @deprecated Usa `OfficeAddressForm` con `surface="modal"`. */
870
+ export const OfficeAddressFormDialog = (
871
+ props: Omit<OfficeAddressFormModalProps, "surface">,
872
+ ) => <OfficeAddressForm {...props} surface="modal" />
873
+
874
+ export type OfficeAddressFormDialogProps = Omit<
875
+ OfficeAddressFormModalProps,
876
+ "surface"
877
+ >