@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.
- package/package.json +15 -1
- package/registry/traky/json/city-select.json +19 -0
- package/registry/traky/json/country-select.json +24 -0
- package/registry/traky/json/geo-search-select.json +24 -0
- package/registry/traky/json/geo-selectors.json +21 -0
- package/registry/traky/json/google-maps-address-picker.json +22 -0
- package/registry/traky/json/internal-chat-mention.json +1 -1
- package/registry/traky/json/internal-chat-panel.json +1 -1
- package/registry/traky/json/lib-geo-city-codes.json +14 -0
- package/registry/traky/json/lib-geo-countries.json +14 -0
- package/registry/traky/json/lib-geo-csc.json +17 -0
- package/registry/traky/json/lib-geo-locations.json +14 -0
- package/registry/traky/json/lib-geo-overrides.json +14 -0
- package/registry/traky/json/lib-geo-types.json +14 -0
- package/registry/traky/json/lib-google-maps-address.json +14 -0
- package/registry/traky/json/lib-phone-countries.json +14 -0
- package/registry/traky/json/lib-phone-e164.json +14 -0
- package/registry/traky/json/lib-utils.json +1 -1
- package/registry/traky/json/office-address-form-dialog.json +17 -0
- package/registry/traky/json/office-address-form.json +31 -0
- package/registry/traky/json/phone-number-input.json +25 -0
- package/registry/traky/json/state-select.json +19 -0
- package/registry/traky/lib/geo-city-codes.ts +234 -0
- package/registry/traky/lib/geo-countries.ts +75 -0
- package/registry/traky/lib/geo-csc.ts +82 -0
- package/registry/traky/lib/geo-locations.ts +171 -0
- package/registry/traky/lib/geo-overrides.ts +375 -0
- package/registry/traky/lib/geo-types.ts +34 -0
- package/registry/traky/lib/google-maps-address.ts +100 -0
- package/registry/traky/lib/phone-countries.ts +380 -0
- package/registry/traky/lib/phone-e164.ts +66 -0
- package/registry/traky/lib/utils.ts +24 -0
- package/registry/traky/registry.json +180 -0
- package/registry/traky/ui/city-select.tsx +113 -0
- package/registry/traky/ui/country-select.tsx +233 -0
- package/registry/traky/ui/geo-search-select.tsx +136 -0
- package/registry/traky/ui/geo-selectors.tsx +162 -0
- package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
- package/registry/traky/ui/internal-chat-mention.ts +34 -3
- package/registry/traky/ui/internal-chat-panel.tsx +147 -12
- package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
- package/registry/traky/ui/office-address-form.tsx +868 -0
- package/registry/traky/ui/phone-number-input.tsx +253 -0
- package/registry/traky/ui/state-select.tsx +107 -0
- package/src/lib/geo-city-codes.ts +234 -0
- package/src/lib/geo-countries.ts +75 -0
- package/src/lib/geo-csc.ts +82 -0
- package/src/lib/geo-locations.ts +171 -0
- package/src/lib/geo-overrides.ts +375 -0
- package/src/lib/geo-types.ts +34 -0
- package/src/lib/google-maps-address.ts +100 -0
- package/src/lib/phone-countries.ts +380 -0
- package/src/lib/phone-e164.ts +66 -0
- package/src/test/setup.ts +31 -0
- package/src/ui/__tests__/geo-selects.test.tsx +504 -0
- package/src/ui/city-select.tsx +113 -0
- package/src/ui/country-select.tsx +233 -0
- package/src/ui/geo-search-select.tsx +136 -0
- package/src/ui/geo-selectors.tsx +162 -0
- package/src/ui/google-maps-address-picker.tsx +422 -0
- package/src/ui/internal-chat-mention.ts +34 -3
- package/src/ui/internal-chat-panel.tsx +147 -12
- package/src/ui/office-address-form-dialog.tsx +16 -0
- package/src/ui/office-address-form.tsx +877 -0
- package/src/ui/phone-number-input.tsx +253 -0
- 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
|
+
>
|