@asteby/metacore-runtime-react 46.3.0 → 47.0.0
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/CHANGELOG.md +31 -0
- package/dist/action-modal-dispatcher.d.ts +10 -1
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +107 -19
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +24 -0
- package/dist/business/index.d.ts +3 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +3 -0
- package/dist/business/print-send-dialog.d.ts +27 -0
- package/dist/business/print-send-dialog.d.ts.map +1 -0
- package/dist/business/print-send-dialog.js +55 -0
- package/dist/business/relate-documents.d.ts +41 -0
- package/dist/business/relate-documents.d.ts.map +1 -0
- package/dist/business/relate-documents.js +42 -0
- package/dist/business/vehicle-picker.d.ts +36 -0
- package/dist/business/vehicle-picker.d.ts.map +1 -0
- package/dist/business/vehicle-picker.js +61 -0
- package/dist/cfdi-stamp-panel.d.ts +29 -0
- package/dist/cfdi-stamp-panel.d.ts.map +1 -0
- package/dist/cfdi-stamp-panel.js +53 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +5 -0
- package/dist/document-form-dialog.d.ts +23 -0
- package/dist/document-form-dialog.d.ts.map +1 -0
- package/dist/document-form-dialog.js +196 -0
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +17 -7
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +3 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/list-primary-action.d.ts +31 -0
- package/dist/list-primary-action.d.ts.map +1 -0
- package/dist/list-primary-action.js +35 -0
- package/dist/model-action-toolbar.d.ts +7 -1
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +10 -3
- package/dist/model-extension-registry.d.ts +7 -0
- package/dist/model-extension-registry.d.ts.map +1 -1
- package/dist/nav-permissions.d.ts +19 -0
- package/dist/nav-permissions.d.ts.map +1 -0
- package/dist/nav-permissions.js +51 -0
- package/dist/navigation-builder.d.ts +6 -0
- package/dist/navigation-builder.d.ts.map +1 -1
- package/dist/navigation-builder.js +26 -1
- package/dist/permissions-manager.d.ts +9 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +50 -17
- package/dist/print-document-button.d.ts.map +1 -1
- package/dist/print-document-button.js +9 -1
- package/dist/record-mutation-events.d.ts +14 -0
- package/dist/record-mutation-events.d.ts.map +1 -0
- package/dist/record-mutation-events.js +37 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-print-document.d.ts +15 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +52 -1
- package/dist/use-record-mutation-tick.d.ts +6 -0
- package/dist/use-record-mutation-tick.d.ts.map +1 -0
- package/dist/use-record-mutation-tick.js +11 -0
- package/package.json +3 -3
- package/src/__tests__/action-modal-feedback.test.tsx +116 -0
- package/src/__tests__/business-pickers.test.tsx +157 -0
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/list-primary-action.test.tsx +157 -0
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/action-modal-dispatcher.tsx +124 -6
- package/src/business/catalog.ts +24 -0
- package/src/business/index.ts +3 -0
- package/src/business/print-send-dialog.tsx +175 -0
- package/src/business/relate-documents.tsx +182 -0
- package/src/business/vehicle-picker.tsx +188 -0
- package/src/cfdi-stamp-panel.tsx +146 -0
- package/src/dialogs/dynamic-record.tsx +5 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dynamic-crud-page.tsx +36 -7
- package/src/dynamic-kanban.tsx +3 -1
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +36 -0
- package/src/list-primary-action.ts +71 -0
- package/src/model-action-toolbar.tsx +18 -3
- package/src/model-extension-registry.tsx +7 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- package/src/permissions-manager.tsx +39 -26
- package/src/print-document-button.tsx +9 -1
- package/src/record-mutation-events.ts +50 -0
- package/src/types.ts +63 -0
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// PrintSendDialog — imprimir o enviar un documento ya emitido (cotización,
|
|
2
|
+
// factura, nota de crédito…). El SDK NO llama a un proveedor de mensajería: el
|
|
3
|
+
// host recibe `onSend(channel, destination, document)` y decide qué hacer.
|
|
4
|
+
// Imprimir usa la URL del PDF que pasa el host (o su propio `onPrint`).
|
|
5
|
+
import { useEffect, useState } from 'react'
|
|
6
|
+
import { useTranslation } from 'react-i18next'
|
|
7
|
+
import { Mail, MessageCircle, Printer, Loader2 } from 'lucide-react'
|
|
8
|
+
import {
|
|
9
|
+
Button,
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
13
|
+
DialogFooter,
|
|
14
|
+
DialogHeader,
|
|
15
|
+
DialogTitle,
|
|
16
|
+
Input,
|
|
17
|
+
} from '@asteby/metacore-ui/primitives'
|
|
18
|
+
import { FormErrorBanner } from './feedback'
|
|
19
|
+
|
|
20
|
+
export type SendChannel = 'email' | 'whatsapp'
|
|
21
|
+
|
|
22
|
+
export interface PrintableDocument {
|
|
23
|
+
folio: string
|
|
24
|
+
/** URL del PDF ya generado. Sin ella no hay «Imprimir». */
|
|
25
|
+
pdfUrl?: string
|
|
26
|
+
/** Texto de cabecera («Factura», «Cotización»). */
|
|
27
|
+
title?: string
|
|
28
|
+
/** Destinos sugeridos (datos del cliente). */
|
|
29
|
+
email?: string
|
|
30
|
+
phone?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PrintSendDialogProps {
|
|
34
|
+
open: boolean
|
|
35
|
+
onOpenChange: (open: boolean) => void
|
|
36
|
+
document: PrintableDocument
|
|
37
|
+
/** Canales de envío disponibles. Default: ambos. Vacío = solo imprimir. */
|
|
38
|
+
channels?: SendChannel[]
|
|
39
|
+
/**
|
|
40
|
+
* Envío a cargo del host. Si rechaza, el mensaje se muestra en el diálogo.
|
|
41
|
+
* Sin `onSend` no se ofrece enviar.
|
|
42
|
+
*/
|
|
43
|
+
onSend?: (channel: SendChannel, destination: string, document: PrintableDocument) => Promise<void> | void
|
|
44
|
+
/** Reemplaza la impresión por defecto (abrir `pdfUrl` en otra pestaña). */
|
|
45
|
+
onPrint?: (document: PrintableDocument) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultPrint(doc: PrintableDocument): void {
|
|
49
|
+
if (!doc.pdfUrl || typeof window === 'undefined') return
|
|
50
|
+
window.open(doc.pdfUrl, '_blank', 'noopener,noreferrer')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function PrintSendDialog({
|
|
54
|
+
open,
|
|
55
|
+
onOpenChange,
|
|
56
|
+
document: doc,
|
|
57
|
+
channels = ['email', 'whatsapp'],
|
|
58
|
+
onSend,
|
|
59
|
+
onPrint,
|
|
60
|
+
}: PrintSendDialogProps) {
|
|
61
|
+
const { t } = useTranslation()
|
|
62
|
+
const [channel, setChannel] = useState<SendChannel>(channels[0] ?? 'email')
|
|
63
|
+
const [destination, setDestination] = useState('')
|
|
64
|
+
const [sending, setSending] = useState(false)
|
|
65
|
+
const [sent, setSent] = useState(false)
|
|
66
|
+
const [error, setError] = useState<string | undefined>()
|
|
67
|
+
|
|
68
|
+
const suggested = channel === 'email' ? doc.email : doc.phone
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (open) {
|
|
71
|
+
setDestination(suggested ?? '')
|
|
72
|
+
setSent(false)
|
|
73
|
+
setError(undefined)
|
|
74
|
+
}
|
|
75
|
+
}, [open, channel, suggested])
|
|
76
|
+
|
|
77
|
+
const canSend = !!onSend && channels.length > 0
|
|
78
|
+
const send = async () => {
|
|
79
|
+
const dest = destination.trim()
|
|
80
|
+
if (!onSend || !dest) return
|
|
81
|
+
setSending(true)
|
|
82
|
+
setError(undefined)
|
|
83
|
+
setSent(false)
|
|
84
|
+
try {
|
|
85
|
+
await onSend(channel, dest, doc)
|
|
86
|
+
setSent(true)
|
|
87
|
+
} catch (e) {
|
|
88
|
+
const msg = (e as { message?: unknown } | undefined)?.message
|
|
89
|
+
setError(
|
|
90
|
+
typeof msg === 'string' && msg
|
|
91
|
+
? msg
|
|
92
|
+
: t('printSend.error', { defaultValue: 'No se pudo enviar el documento. Inténtalo de nuevo.' }),
|
|
93
|
+
)
|
|
94
|
+
} finally {
|
|
95
|
+
setSending(false)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const channelLabel = (c: SendChannel) =>
|
|
100
|
+
c === 'email' ? t('printSend.email', { defaultValue: 'Correo' }) : t('printSend.whatsapp', { defaultValue: 'WhatsApp' })
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
104
|
+
<DialogContent className="sm:max-w-md">
|
|
105
|
+
<DialogHeader>
|
|
106
|
+
<DialogTitle>
|
|
107
|
+
{t('printSend.title', { defaultValue: 'Imprimir o enviar' })}
|
|
108
|
+
{doc.title ? ` · ${doc.title}` : ''}
|
|
109
|
+
</DialogTitle>
|
|
110
|
+
<DialogDescription>{doc.folio}</DialogDescription>
|
|
111
|
+
</DialogHeader>
|
|
112
|
+
|
|
113
|
+
<div className="space-y-4">
|
|
114
|
+
<Button
|
|
115
|
+
type="button"
|
|
116
|
+
variant="outline"
|
|
117
|
+
className="w-full justify-start"
|
|
118
|
+
disabled={!doc.pdfUrl && !onPrint}
|
|
119
|
+
onClick={() => (onPrint ?? defaultPrint)(doc)}
|
|
120
|
+
>
|
|
121
|
+
<Printer className="mr-2 size-4" aria-hidden />
|
|
122
|
+
{t('printSend.print', { defaultValue: 'Imprimir' })}
|
|
123
|
+
</Button>
|
|
124
|
+
|
|
125
|
+
{canSend && (
|
|
126
|
+
<div className="space-y-2">
|
|
127
|
+
{channels.length > 1 && (
|
|
128
|
+
<div className="flex gap-2" role="group" aria-label={t('printSend.channel', { defaultValue: 'Canal de envío' })}>
|
|
129
|
+
{channels.map((c) => (
|
|
130
|
+
<Button
|
|
131
|
+
key={c}
|
|
132
|
+
type="button"
|
|
133
|
+
size="sm"
|
|
134
|
+
variant={c === channel ? 'default' : 'outline'}
|
|
135
|
+
aria-pressed={c === channel}
|
|
136
|
+
onClick={() => setChannel(c)}
|
|
137
|
+
>
|
|
138
|
+
{c === 'email' ? <Mail className="mr-1.5 size-4" aria-hidden /> : <MessageCircle className="mr-1.5 size-4" aria-hidden />}
|
|
139
|
+
{channelLabel(c)}
|
|
140
|
+
</Button>
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
<Input
|
|
145
|
+
value={destination}
|
|
146
|
+
type={channel === 'email' ? 'email' : 'tel'}
|
|
147
|
+
aria-label={t('printSend.destination', { defaultValue: 'Destino' })}
|
|
148
|
+
placeholder={channel === 'email' ? 'cliente@ejemplo.com' : '+52 55 0000 0000'}
|
|
149
|
+
onChange={(e) => setDestination(e.target.value)}
|
|
150
|
+
/>
|
|
151
|
+
<FormErrorBanner message={error} />
|
|
152
|
+
{sent && (
|
|
153
|
+
<p role="status" className="text-sm text-emerald-600 dark:text-emerald-400">
|
|
154
|
+
{t('printSend.sent', { defaultValue: 'Enviado.' })}
|
|
155
|
+
</p>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<DialogFooter>
|
|
162
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
163
|
+
{t('common.close', { defaultValue: 'Cerrar' })}
|
|
164
|
+
</Button>
|
|
165
|
+
{canSend && (
|
|
166
|
+
<Button type="button" onClick={send} disabled={sending || !destination.trim()}>
|
|
167
|
+
{sending && <Loader2 className="mr-2 size-4 animate-spin" aria-hidden />}
|
|
168
|
+
{t('printSend.send', { defaultValue: 'Enviar' })}
|
|
169
|
+
</Button>
|
|
170
|
+
)}
|
|
171
|
+
</DialogFooter>
|
|
172
|
+
</DialogContent>
|
|
173
|
+
</Dialog>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// RelateDocuments — relaciona uno o más documentos origen (CFDI) desde un
|
|
2
|
+
// formulario: nota de crédito, devolución, sustitución. El valor es DATO
|
|
3
|
+
// (`related_document_id`, `relation_type`, UUID/folio para mostrar), no un flujo
|
|
4
|
+
// de un solo módulo. El SDK no embebe catálogos fiscales: los tipos de relación
|
|
5
|
+
// (p. ej. SAT 01/03/04) llegan por la prop `relationTypes`.
|
|
6
|
+
import { useState } from 'react'
|
|
7
|
+
import { useTranslation } from 'react-i18next'
|
|
8
|
+
import { Link2, Search, X } from 'lucide-react'
|
|
9
|
+
import { Badge, Button, Input } from '@asteby/metacore-ui'
|
|
10
|
+
import { useAsyncSearch } from './use-async-search'
|
|
11
|
+
|
|
12
|
+
export interface RelatableDocument {
|
|
13
|
+
id: string
|
|
14
|
+
/** UUID fiscal (CFDI), cuando ya está timbrado. */
|
|
15
|
+
uuid?: string
|
|
16
|
+
folio?: string
|
|
17
|
+
/** Texto libre para mostrar («Factura A-120 · Cliente X»). */
|
|
18
|
+
label?: string
|
|
19
|
+
total?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RelationTypeOption {
|
|
23
|
+
/** Clave que viaja en `relation_type` (p. ej. `"01"`). */
|
|
24
|
+
value: string
|
|
25
|
+
label: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Un documento relacionado: lo que el formulario guarda. */
|
|
29
|
+
export interface RelatedDocument {
|
|
30
|
+
related_document_id: string
|
|
31
|
+
relation_type: string
|
|
32
|
+
/** Solo para mostrar. */
|
|
33
|
+
uuid?: string
|
|
34
|
+
folio?: string
|
|
35
|
+
label?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RelateDocumentsProps {
|
|
39
|
+
value: RelatedDocument[]
|
|
40
|
+
onChange: (value: RelatedDocument[]) => void
|
|
41
|
+
/** Catálogo de tipos de relación (lo aporta el addon). */
|
|
42
|
+
relationTypes: RelationTypeOption[]
|
|
43
|
+
/** Búsqueda de documentos origen. Estable (useCallback). */
|
|
44
|
+
search: (q: string, signal: AbortSignal) => Promise<RelatableDocument[]>
|
|
45
|
+
/** Tipo preseleccionado al agregar. Default: el primero del catálogo. */
|
|
46
|
+
defaultRelationType?: string
|
|
47
|
+
/** Máximo de documentos relacionados. Sin tope por defecto. */
|
|
48
|
+
max?: number
|
|
49
|
+
disabled?: boolean
|
|
50
|
+
placeholder?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function docTitle(d: { folio?: string; label?: string; uuid?: string; id?: string }): string {
|
|
54
|
+
return d.label || d.folio || d.uuid || d.id || ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function RelateDocuments({
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
relationTypes,
|
|
61
|
+
search,
|
|
62
|
+
defaultRelationType,
|
|
63
|
+
max,
|
|
64
|
+
disabled,
|
|
65
|
+
placeholder,
|
|
66
|
+
}: RelateDocumentsProps) {
|
|
67
|
+
const { t } = useTranslation()
|
|
68
|
+
const [text, setText] = useState('')
|
|
69
|
+
const [pendingType, setPendingType] = useState<string>(defaultRelationType ?? relationTypes[0]?.value ?? '')
|
|
70
|
+
const { results, loading, error } = useAsyncSearch(text, search, { minChars: 2 })
|
|
71
|
+
const full = max != null && value.length >= max
|
|
72
|
+
const typeLabel = (v: string) => relationTypes.find((r) => r.value === v)?.label ?? v
|
|
73
|
+
|
|
74
|
+
const add = (d: RelatableDocument) => {
|
|
75
|
+
if (full || value.some((x) => x.related_document_id === d.id)) return
|
|
76
|
+
onChange([
|
|
77
|
+
...value,
|
|
78
|
+
{ related_document_id: d.id, relation_type: pendingType, uuid: d.uuid, folio: d.folio, label: d.label },
|
|
79
|
+
])
|
|
80
|
+
setText('')
|
|
81
|
+
}
|
|
82
|
+
const remove = (id: string) => onChange(value.filter((x) => x.related_document_id !== id))
|
|
83
|
+
const setType = (id: string, relation_type: string) =>
|
|
84
|
+
onChange(value.map((x) => (x.related_document_id === id ? { ...x, relation_type } : x)))
|
|
85
|
+
|
|
86
|
+
const selectCls = 'h-9 rounded-md border bg-background px-2 text-sm'
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div data-slot="relate-documents" className="space-y-2">
|
|
90
|
+
{value.length > 0 && (
|
|
91
|
+
<ul className="divide-y rounded-md border">
|
|
92
|
+
{value.map((d) => (
|
|
93
|
+
<li key={d.related_document_id} className="flex flex-wrap items-center gap-2 px-2 py-1.5">
|
|
94
|
+
<Link2 className="size-4 text-muted-foreground" aria-hidden />
|
|
95
|
+
<div className="min-w-0 flex-1">
|
|
96
|
+
<p className="truncate text-sm font-medium">{docTitle({ folio: d.folio, label: d.label, id: d.related_document_id })}</p>
|
|
97
|
+
{d.uuid && <p className="break-all font-mono text-xs text-muted-foreground">{d.uuid}</p>}
|
|
98
|
+
</div>
|
|
99
|
+
<select
|
|
100
|
+
className={selectCls}
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
value={d.relation_type}
|
|
103
|
+
aria-label={t('relateDocuments.type', { defaultValue: 'Tipo de relación' })}
|
|
104
|
+
onChange={(e) => setType(d.related_document_id, e.target.value)}
|
|
105
|
+
>
|
|
106
|
+
{!relationTypes.some((r) => r.value === d.relation_type) && (
|
|
107
|
+
<option value={d.relation_type}>{typeLabel(d.relation_type)}</option>
|
|
108
|
+
)}
|
|
109
|
+
{relationTypes.map((r) => (
|
|
110
|
+
<option key={r.value} value={r.value}>{r.label}</option>
|
|
111
|
+
))}
|
|
112
|
+
</select>
|
|
113
|
+
{!disabled && (
|
|
114
|
+
<Button type="button" size="icon" variant="ghost" onClick={() => remove(d.related_document_id)} aria-label={t('relateDocuments.remove', { defaultValue: 'Quitar relación' })}>
|
|
115
|
+
<X className="size-4" />
|
|
116
|
+
</Button>
|
|
117
|
+
)}
|
|
118
|
+
</li>
|
|
119
|
+
))}
|
|
120
|
+
</ul>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{!full && !disabled && (
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<div className="flex items-center gap-1.5">
|
|
126
|
+
<div className="relative flex-1">
|
|
127
|
+
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
|
|
128
|
+
<Input
|
|
129
|
+
className="pl-8"
|
|
130
|
+
value={text}
|
|
131
|
+
placeholder={placeholder ?? t('relateDocuments.placeholder', { defaultValue: 'Buscar por folio o UUID' })}
|
|
132
|
+
onChange={(e) => setText(e.target.value)}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
<select
|
|
136
|
+
className={selectCls}
|
|
137
|
+
value={pendingType}
|
|
138
|
+
aria-label={t('relateDocuments.newType', { defaultValue: 'Tipo para el documento nuevo' })}
|
|
139
|
+
onChange={(e) => setPendingType(e.target.value)}
|
|
140
|
+
>
|
|
141
|
+
{relationTypes.map((r) => (
|
|
142
|
+
<option key={r.value} value={r.value}>{r.label}</option>
|
|
143
|
+
))}
|
|
144
|
+
</select>
|
|
145
|
+
</div>
|
|
146
|
+
{loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
|
|
147
|
+
{error && !loading && (
|
|
148
|
+
<p role="alert" className="text-sm text-destructive">
|
|
149
|
+
{t('relateDocuments.error', { defaultValue: 'No se pudo buscar documentos. Inténtalo de nuevo.' })}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
{!loading && !error && text.trim().length >= 2 && results.length === 0 && (
|
|
153
|
+
<p className="text-sm text-muted-foreground">{t('relateDocuments.empty', { defaultValue: 'No encontramos ese documento.' })}</p>
|
|
154
|
+
)}
|
|
155
|
+
{results.length > 0 && (
|
|
156
|
+
<ul role="listbox" className="max-h-64 divide-y overflow-auto rounded-md border">
|
|
157
|
+
{results.map((d) => {
|
|
158
|
+
const already = value.some((x) => x.related_document_id === d.id)
|
|
159
|
+
return (
|
|
160
|
+
<li key={d.id} role="option" aria-selected={already}>
|
|
161
|
+
<button type="button" disabled={already} className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-accent disabled:opacity-50" onClick={() => add(d)}>
|
|
162
|
+
<span>
|
|
163
|
+
<span className="block text-sm font-medium">{docTitle(d)}</span>
|
|
164
|
+
{d.uuid && <span className="block break-all font-mono text-xs text-muted-foreground">{d.uuid}</span>}
|
|
165
|
+
</span>
|
|
166
|
+
{already && <Badge variant="muted">{t('relateDocuments.added', { defaultValue: 'Agregado' })}</Badge>}
|
|
167
|
+
</button>
|
|
168
|
+
</li>
|
|
169
|
+
)
|
|
170
|
+
})}
|
|
171
|
+
</ul>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Valor a enviar al servidor: solo los datos, sin los campos de visualización. */
|
|
180
|
+
export function serializeRelatedDocuments(value: RelatedDocument[]): Array<Pick<RelatedDocument, 'related_document_id' | 'relation_type'>> {
|
|
181
|
+
return value.map(({ related_document_id, relation_type }) => ({ related_document_id, relation_type }))
|
|
182
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// VehiclePicker + alta rápida — busca por placa o VIN (y marca/modelo/cliente que
|
|
2
|
+
// el host decida indexar) y reutiliza el <CreateRecordDialog> dinámico del modelo
|
|
3
|
+
// vehículo para el alta rápida. Mismo contrato que <CustomerPicker>: controlado
|
|
4
|
+
// (`value` / `onChange`), `search` inyectado por el host, permisos con useCan
|
|
5
|
+
// (sin <PermissionsProvider> no bloquea nada: el backend autoriza).
|
|
6
|
+
import { useCallback, useState } from 'react'
|
|
7
|
+
import { useTranslation } from 'react-i18next'
|
|
8
|
+
import { Car, Plus, Search, X } from 'lucide-react'
|
|
9
|
+
import { Button, Input } from '@asteby/metacore-ui'
|
|
10
|
+
import { CreateRecordDialog } from '../dialogs/create-record-dialog'
|
|
11
|
+
import { useApi } from '../api-context'
|
|
12
|
+
import { useCan } from '../permissions-context'
|
|
13
|
+
import { useAsyncSearch } from './use-async-search'
|
|
14
|
+
import { EmptyState } from './feedback'
|
|
15
|
+
|
|
16
|
+
export interface VehicleResult {
|
|
17
|
+
id: string
|
|
18
|
+
/** Placa tal como la capturó el taller (sin formato hardcodeado por país). */
|
|
19
|
+
plate?: string
|
|
20
|
+
vin?: string
|
|
21
|
+
make?: string
|
|
22
|
+
model?: string
|
|
23
|
+
year?: number | string
|
|
24
|
+
color?: string
|
|
25
|
+
/** Propietario, cuando el host lo expone. */
|
|
26
|
+
customer_id?: string
|
|
27
|
+
customer_name?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface VehiclePickerProps {
|
|
31
|
+
value: VehicleResult | null
|
|
32
|
+
/** Evento único de cambio: vehículo elegido/creado, o `null` al limpiar. */
|
|
33
|
+
onChange: (vehicle: VehicleResult | null) => void
|
|
34
|
+
/** Búsqueda por placa o VIN. Estable (useCallback). */
|
|
35
|
+
search: (q: string, signal: AbortSignal) => Promise<VehicleResult[]>
|
|
36
|
+
/** Modelo del kernel para el alta rápida. Default `Vehicle`. */
|
|
37
|
+
model?: string
|
|
38
|
+
/** Endpoint CRUD del alta. Default `/data/<model>/me`. */
|
|
39
|
+
endpoint?: string
|
|
40
|
+
/** Convierte el registro guardado a `VehicleResult`. Default: id, placa, VIN, marca, modelo, año. */
|
|
41
|
+
mapRecord?: (rec: Record<string, unknown>) => VehicleResult
|
|
42
|
+
/** Override de permiso (default `<model>.create` con useCan). */
|
|
43
|
+
canCreate?: boolean
|
|
44
|
+
/** Valores iniciales del alta (p. ej. `customer_id` del cliente ya elegido). */
|
|
45
|
+
createDefaults?: Record<string, unknown>
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
placeholder?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function snake(model: string): string {
|
|
51
|
+
return model.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
|
|
55
|
+
|
|
56
|
+
/** Línea corta de un vehículo: «Nissan Versa 2019 · ABC-123 · VIN…». */
|
|
57
|
+
export function describeVehicle(v: VehicleResult): string {
|
|
58
|
+
const unit = [v.make, v.model, v.year].filter((x) => x != null && x !== '').join(' ')
|
|
59
|
+
return [unit, v.plate, v.vin].filter(Boolean).join(' · ')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function VehiclePicker({
|
|
63
|
+
value,
|
|
64
|
+
onChange,
|
|
65
|
+
search,
|
|
66
|
+
model = 'Vehicle',
|
|
67
|
+
endpoint,
|
|
68
|
+
mapRecord,
|
|
69
|
+
canCreate,
|
|
70
|
+
createDefaults,
|
|
71
|
+
disabled,
|
|
72
|
+
placeholder,
|
|
73
|
+
}: VehiclePickerProps) {
|
|
74
|
+
const { t } = useTranslation()
|
|
75
|
+
const api = useApi()
|
|
76
|
+
const can = useCan()
|
|
77
|
+
const [text, setText] = useState('')
|
|
78
|
+
const [creating, setCreating] = useState(false)
|
|
79
|
+
const { results, loading, error } = useAsyncSearch(text, search, { minChars: 2 })
|
|
80
|
+
const mayCreate = canCreate ?? can(`${snake(model)}.create`)
|
|
81
|
+
const base = endpoint ?? `/data/${model}/me`
|
|
82
|
+
|
|
83
|
+
const pick = (v: VehicleResult) => {
|
|
84
|
+
onChange(v)
|
|
85
|
+
setText('')
|
|
86
|
+
}
|
|
87
|
+
const toResult = useCallback(
|
|
88
|
+
(rec: Record<string, unknown>): VehicleResult =>
|
|
89
|
+
mapRecord
|
|
90
|
+
? mapRecord(rec)
|
|
91
|
+
: {
|
|
92
|
+
id: String(rec.id ?? ''),
|
|
93
|
+
plate: str(rec.plate),
|
|
94
|
+
vin: str(rec.vin),
|
|
95
|
+
make: str(rec.make),
|
|
96
|
+
model: str(rec.model),
|
|
97
|
+
year: typeof rec.year === 'number' || typeof rec.year === 'string' ? rec.year : undefined,
|
|
98
|
+
color: str(rec.color),
|
|
99
|
+
customer_id: str(rec.customer_id),
|
|
100
|
+
},
|
|
101
|
+
[mapRecord],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if (value) {
|
|
105
|
+
return (
|
|
106
|
+
<div data-slot="vehicle-picker" className="flex items-start justify-between gap-2 rounded-md border p-2">
|
|
107
|
+
<div className="flex items-start gap-2">
|
|
108
|
+
<Car className="mt-0.5 size-4 text-muted-foreground" aria-hidden />
|
|
109
|
+
<div>
|
|
110
|
+
<p className="text-sm font-medium">{value.plate || value.vin || t('vehiclePicker.noPlate', { defaultValue: 'Sin placa' })}</p>
|
|
111
|
+
<p className="text-xs text-muted-foreground">
|
|
112
|
+
{[[value.make, value.model, value.year].filter((x) => x != null && x !== '').join(' '), value.vin && value.plate ? value.vin : '', value.customer_name]
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.join(' · ')}
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
{!disabled && (
|
|
119
|
+
<Button type="button" size="icon" variant="ghost" onClick={() => onChange(null)} aria-label={t('vehiclePicker.clear', { defaultValue: 'Quitar vehículo' })}>
|
|
120
|
+
<X className="size-4" />
|
|
121
|
+
</Button>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div data-slot="vehicle-picker" className="space-y-2">
|
|
129
|
+
<div className="flex items-center gap-1.5">
|
|
130
|
+
<div className="relative flex-1">
|
|
131
|
+
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
|
|
132
|
+
<Input
|
|
133
|
+
className="pl-8"
|
|
134
|
+
disabled={disabled}
|
|
135
|
+
value={text}
|
|
136
|
+
placeholder={placeholder ?? t('vehiclePicker.placeholder', { defaultValue: 'Placa o VIN' })}
|
|
137
|
+
onChange={(e) => setText(e.target.value)}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
{mayCreate && (
|
|
141
|
+
<Button type="button" variant="outline" size="icon" disabled={disabled} onClick={() => setCreating(true)} aria-label={t('vehiclePicker.create', { defaultValue: 'Nuevo vehículo' })}>
|
|
142
|
+
<Plus className="size-4" />
|
|
143
|
+
</Button>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
{loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
|
|
147
|
+
{error && !loading && (
|
|
148
|
+
<p role="alert" className="text-sm text-destructive">
|
|
149
|
+
{t('vehiclePicker.error', { defaultValue: 'No se pudo buscar vehículos. Inténtalo de nuevo.' })}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
{!loading && !error && text.trim().length >= 2 && results.length === 0 && (
|
|
153
|
+
<EmptyState
|
|
154
|
+
title={t('vehiclePicker.empty', { defaultValue: 'No encontramos ese vehículo' })}
|
|
155
|
+
action={mayCreate ? { label: t('vehiclePicker.createAction', { defaultValue: 'Crear vehículo' }), onClick: () => setCreating(true) } : undefined}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{results.length > 0 && (
|
|
159
|
+
<ul role="listbox" className="max-h-64 divide-y overflow-auto rounded-md border">
|
|
160
|
+
{results.map((v) => (
|
|
161
|
+
<li key={v.id} role="option" aria-selected={false}>
|
|
162
|
+
<button type="button" className="flex w-full flex-col px-2 py-1.5 text-left hover:bg-accent" onClick={() => pick(v)}>
|
|
163
|
+
<span className="text-sm font-medium">{v.plate || v.vin || v.id}</span>
|
|
164
|
+
<span className="text-xs text-muted-foreground">{[describeVehicle({ ...v, plate: undefined }), v.customer_name].filter(Boolean).join(' · ')}</span>
|
|
165
|
+
</button>
|
|
166
|
+
</li>
|
|
167
|
+
))}
|
|
168
|
+
</ul>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{creating && (
|
|
172
|
+
<CreateRecordDialog
|
|
173
|
+
modelKey={model}
|
|
174
|
+
open={creating}
|
|
175
|
+
onOpenChange={setCreating}
|
|
176
|
+
endpoint={base}
|
|
177
|
+
defaults={createDefaults ?? (text.trim() ? { plate: text.trim() } : undefined)}
|
|
178
|
+
onCreate={async (data) => {
|
|
179
|
+
const res = await api.post(base, data)
|
|
180
|
+
const rec = (res.data?.data ?? res.data) as Record<string, unknown>
|
|
181
|
+
pick(toResult(rec))
|
|
182
|
+
return rec.id != null ? { id: String(rec.id) } : undefined
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|