@traky-app/ui 1.6.3 → 1.6.5

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