@asteby/metacore-runtime-react 49.1.0 → 49.2.1

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 (39) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/action-modal-dispatcher.d.ts +1 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +34 -5
  5. package/dist/business/document-editor-model.d.ts +27 -1
  6. package/dist/business/document-editor-model.d.ts.map +1 -1
  7. package/dist/business/document-editor-model.js +104 -7
  8. package/dist/business/document-editor-panels.d.ts +18 -0
  9. package/dist/business/document-editor-panels.d.ts.map +1 -1
  10. package/dist/business/document-editor-panels.js +9 -0
  11. package/dist/business/document-editor.d.ts +7 -1
  12. package/dist/business/document-editor.d.ts.map +1 -1
  13. package/dist/business/document-editor.js +106 -41
  14. package/dist/document-form-dialog.d.ts.map +1 -1
  15. package/dist/document-form-dialog.js +8 -3
  16. package/dist/dynamic-row-actions.d.ts +6 -0
  17. package/dist/dynamic-row-actions.d.ts.map +1 -1
  18. package/dist/dynamic-row-actions.js +9 -4
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/record-mutation-events.d.ts +12 -0
  23. package/dist/record-mutation-events.d.ts.map +1 -1
  24. package/dist/record-mutation-events.js +18 -0
  25. package/package.json +1 -1
  26. package/src/__tests__/document-editor-model.test.ts +27 -0
  27. package/src/__tests__/document-editor.test.tsx +60 -0
  28. package/src/__tests__/invoice-document-editor.test.tsx +267 -0
  29. package/src/__tests__/r5-self-label-settled.test.ts +57 -0
  30. package/src/__tests__/r6-editor-source-seeds.test.tsx +137 -0
  31. package/src/__tests__/row-action-link-navigation.test.ts +10 -6
  32. package/src/action-modal-dispatcher.tsx +34 -5
  33. package/src/business/document-editor-model.ts +118 -7
  34. package/src/business/document-editor-panels.tsx +49 -0
  35. package/src/business/document-editor.tsx +288 -177
  36. package/src/document-form-dialog.tsx +13 -4
  37. package/src/dynamic-row-actions.tsx +12 -4
  38. package/src/index.ts +2 -0
  39. package/src/record-mutation-events.ts +23 -0
@@ -54,7 +54,9 @@ function declaredDefault(f: ActionFieldDef): unknown {
54
54
 
55
55
  /**
56
56
  * Divulgación progresiva: ¿el campo va plegado en «Opciones fiscales»?
57
- * - campos de extensión (`fiscal_data.*`) que aportan otros addons;
57
+ * - campos de extensión (`fiscal_data.*`) que aportan otros addons (los que
58
+ * el propio tipo declara obligatorios, p. ej. método y forma de pago de la
59
+ * factura, se marcan esenciales en splitEditorFields);
58
60
  * - `section: "fiscal" | "advanced"`;
59
61
  * - catálogos opcionales que ya traen un default (método, serie, relación…):
60
62
  * el usuario no necesita tocarlos para guardar.
@@ -84,7 +86,10 @@ export function splitEditorFields(
84
86
  fields: readonly ActionFieldDef[],
85
87
  opts: { partyField?: string; essentialKeys?: readonly string[]; extensionFields?: readonly ActionFieldDef[] } = {},
86
88
  ): EditorFieldGroups {
87
- const essentialKeys = new Set(opts.essentialKeys ?? [])
89
+ // Lo obligatorio que el tipo declara siempre está a la vista, aunque sea de
90
+ // extensión (`fiscal_data.metodo_pago`): es lo que el usuario debe decidir.
91
+ // Las extensiones que llegan del metadata (no declaradas) siguen plegadas.
92
+ const essentialKeys = new Set([...(opts.essentialKeys ?? []), ...fields.filter((f) => f.required).map((f) => f.key)])
88
93
  const out: EditorFieldGroups = { essential: [], advanced: [], notes: [] }
89
94
  const seen = new Set<string>()
90
95
  for (const f of [...fields, ...(opts.extensionFields ?? [])]) {
@@ -200,7 +205,7 @@ export function creditStatus(
200
205
  export function linesFromSource(
201
206
  rows: unknown,
202
207
  map: Record<string, string> = {},
203
- opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode'] } = {},
208
+ opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode']; defaultTaxRate?: number } = {},
204
209
  ): LineItem[] {
205
210
  if (!Array.isArray(rows)) return []
206
211
  return rows.filter((raw) => !isFullyConsumed(raw)).map((raw) => {
@@ -212,13 +217,18 @@ export function linesFromSource(
212
217
  if (!hasValue(rate)) {
213
218
  const sub = toAmount(get('subtotal'))
214
219
  const tax = toAmount(get('tax_amount'))
215
- rate = sub > 0 ? Math.round((tax / sub) * 10000) / 10000 : 0
220
+ // Un origen sin dato de impuesto (p. ej. una OT de taller: solo
221
+ // cantidad y precio) toma la tasa de la org, como un renglón nuevo.
222
+ rate = hasValue(get('tax_amount'))
223
+ ? sub > 0 ? Math.round((tax / sub) * 10000) / 10000 : 0
224
+ : opts.defaultTaxRate ?? 0
216
225
  }
217
226
  const r2 = toAmount(rate)
227
+ const product = get('product_id')
218
228
  const line = makeLine({
219
- product_id: get('product_id') ?? undefined,
229
+ product_id: refValue(product),
220
230
  sku: get('sku') ?? undefined,
221
- description: String(get('description') ?? get('product_name') ?? ''),
231
+ description: String(get('description') ?? get('product_name') ?? refLabel(product) ?? refLabel(r.product) ?? ''),
222
232
  quantity: qty,
223
233
  unit_price: toAmount(get('unit_price')),
224
234
  discount: toAmount(get('discount') ?? 0),
@@ -236,11 +246,40 @@ export function linesFromSource(
236
246
  })
237
247
  }
238
248
 
249
+ /** Id de una celda de relación (`{value,label}`, `{id,name}` o el id plano). */
250
+ function refValue(v: unknown): string | undefined {
251
+ if (v == null || v === '') return undefined
252
+ if (typeof v === 'object') {
253
+ const o = v as Record<string, unknown>
254
+ const id = o.value ?? o.id
255
+ return id == null || id === '' ? undefined : String(id)
256
+ }
257
+ return String(v)
258
+ }
259
+
260
+ /**
261
+ * Nombre de una celda de relación ya resuelta por el host (`{label}`/`{name}`).
262
+ * Un renglón de origen sin descripción propia (InvoiceItem solo lleva el
263
+ * producto) se nombra con su producto en vez de entrar en blanco.
264
+ */
265
+ function refLabel(v: unknown): string | undefined {
266
+ if (!v || typeof v !== 'object') return undefined
267
+ const o = v as Record<string, unknown>
268
+ const l = o.label ?? o.name
269
+ return typeof l === 'string' && l.trim() ? l : undefined
270
+ }
271
+
239
272
  function isFullyConsumed(raw: unknown): boolean {
240
273
  const r = (raw ?? {}) as Record<string, any>
241
274
  return hasValue(r.remaining_quantity) && toAmount(r.remaining_quantity) <= 0
242
275
  }
243
276
 
277
+ /** El editor ocupa toda la pantalla: documentos comerciales con renglones (factura, cotización, pedido, OC). */
278
+ export function isFullscreenEditor(type: DocumentFormType, forms: Pick<DocumentFormsManifest, 'lines_field'>): boolean {
279
+ const kind = editorLinesConfig(type, forms)?.kind ?? 'sale'
280
+ return !!type.lines && (kind === 'sale' || kind === 'purchase')
281
+ }
282
+
244
283
  /** Cuántos renglones del origen se cargaron y cuántos se omitieron por estar ya cubiertos. */
245
284
  export function sourceLoadSummary(rows: unknown): { loaded: number; covered: number } {
246
285
  if (!Array.isArray(rows)) return { loaded: 0, covered: 0 }
@@ -253,6 +292,74 @@ export function sourceTracksRemaining(src: Pick<DocumentFormSource, 'line_link_f
253
292
  return !!(src.line_link_field || src.remaining_qty_field || src.remaining_endpoint)
254
293
  }
255
294
 
295
+ /** Opción ya resuelta de un selector de la cabecera que el editor llenó por código. */
296
+ export interface SeedLabel {
297
+ value: string
298
+ label: string
299
+ }
300
+
301
+ /** Columnas con las que se nombra un documento (folio antes que nombre). */
302
+ const DOCUMENT_LABEL_COLUMNS = ['number', 'folio', 'order_number', 'name', 'title', 'code']
303
+
304
+ function declaredOptionLabel(f: ActionFieldDef | undefined): string | undefined {
305
+ const opts = (f as { options?: unknown } | undefined)?.options
306
+ const l = opts && !Array.isArray(opts) && typeof opts === 'object' ? (opts as { label?: unknown }).label : undefined
307
+ return typeof l === 'string' && l ? l : undefined
308
+ }
309
+
310
+ /** Nombre visible de un registro: la columna declarada por el campo, luego folio/nombre. */
311
+ export function recordLabel(rec: Record<string, any> | null | undefined, preferred?: string): string | undefined {
312
+ if (!rec) return undefined
313
+ for (const c of preferred ? [preferred, ...DOCUMENT_LABEL_COLUMNS] : DOCUMENT_LABEL_COLUMNS) {
314
+ const v = rec[c]
315
+ if ((typeof v === 'string' && v.trim()) || typeof v === 'number') return String(v)
316
+ }
317
+ return undefined
318
+ }
319
+
320
+ /**
321
+ * Etiquetas de lo que «Cargar desde…» escribe en la cabecera, para que el
322
+ * selector muestre el nombre y no el UUID sin abrirse:
323
+ * - `header` (`customer_id ← customer_id`): el objeto hermano `{value,label}`
324
+ * que el host sirve junto a la FK (`customer`);
325
+ * - `link_field`: el propio documento origen, con su folio.
326
+ */
327
+ export function sourceHeaderSeeds(
328
+ src: Pick<DocumentFormSource, 'header' | 'link_field'>,
329
+ rec: Record<string, any>,
330
+ id: string,
331
+ fields: readonly ActionFieldDef[] = [],
332
+ ): Record<string, SeedLabel> {
333
+ const out: Record<string, SeedLabel> = {}
334
+ for (const [to, from] of Object.entries(src.header ?? {})) {
335
+ if (!from.endsWith('_id')) continue
336
+ const v = refValue(rec[from])
337
+ const label = refLabel(rec[from]) ?? refLabel(rec[from.slice(0, -3)])
338
+ if (v && label) out[to] = { value: v, label }
339
+ }
340
+ if (src.link_field) {
341
+ const label = recordLabel(rec, declaredOptionLabel(fields.find((f) => f.key === src.link_field)))
342
+ if (label) out[src.link_field] = { value: id, label }
343
+ }
344
+ return out
345
+ }
346
+
347
+ /**
348
+ * Registro para `renderField` con las etiquetas sembradas como objeto hermano
349
+ * (`customer_id` → `customer: {value,label}`), el mismo contrato que sirve el
350
+ * host en una fila. Conserva el registro desde el que se abrió.
351
+ */
352
+ export function seedRecord(
353
+ seeds: Record<string, SeedLabel>,
354
+ record?: Record<string, any>,
355
+ ): Record<string, any> | undefined {
356
+ const keys = Object.keys(seeds).filter((k) => k.endsWith('_id'))
357
+ if (keys.length === 0) return record
358
+ const out: Record<string, any> = { ...(record ?? {}) }
359
+ for (const k of keys) out[k.slice(0, -3)] = seeds[k]
360
+ return out
361
+ }
362
+
256
363
  export interface EditorIssue {
257
364
  field?: string
258
365
  severity: 'error' | 'warning'
@@ -267,7 +374,11 @@ export function localIssues(lines: LineItem[], opts: { requireLines?: boolean; r
267
374
  issues.push({ field: 'lines', severity: 'error', message: 'Agrega al menos un renglón.' })
268
375
  }
269
376
  items.forEach((l, i) => {
270
- if (!l.description.trim()) issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: falta la descripción.` })
377
+ // Un renglón con producto se describe con él (el servidor lo nombra
378
+ // desde el catálogo): solo un renglón libre exige texto, como en el
379
+ // editor de renglones (validateLineItems).
380
+ if (!l.description.trim() && !l.product_id)
381
+ issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: elige un producto o escribe la descripción.` })
271
382
  if (toAmount(l.unit_price) <= 0) issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: precio en cero.` })
272
383
  if (l.max_quantity != null && toAmount(l.quantity) > toAmount(l.max_quantity)) {
273
384
  issues.push({
@@ -7,6 +7,7 @@ import { Badge } from '@asteby/metacore-ui/primitives'
7
7
  import { AlertTriangle, ChevronDown, XCircle } from 'lucide-react'
8
8
  import type { CreditStatus, EditorIssue } from './document-editor-model'
9
9
  import type { Formatter } from './format'
10
+ import { computeLine, type LineItem } from './line-items'
10
11
 
11
12
  /** Bloque con título discreto y espaciado generoso (sin bordes pesados). */
12
13
  export function EditorSection({ title, hint, children, slot }: { title?: ReactNode; hint?: ReactNode; children: ReactNode; slot?: string }) {
@@ -162,3 +163,51 @@ export function PreviewPanel({ preview }: { preview: PreviewContent | null }) {
162
163
  if (preview.xml) return <pre className="max-h-96 overflow-auto rounded bg-muted p-3 text-[11px] leading-tight" data-slot="preview-xml">{preview.xml}</pre>
163
164
  return null
164
165
  }
166
+
167
+ export interface DraftPreviewProps {
168
+ title: string
169
+ party?: string
170
+ /** Datos del encabezado ya con su etiqueta y valor legibles. */
171
+ header: Array<{ label: string; value: string }>
172
+ lines: LineItem[]
173
+ totals: TotalsRow[]
174
+ fmt: Formatter
175
+ }
176
+
177
+ /**
178
+ * Vista previa local del documento (sin servidor ni borrador): cómo queda con
179
+ * lo capturado. Para tipos sin acción `preview`; la del servidor (PDF/XML) gana.
180
+ */
181
+ export function DraftPreview({ title, party, header, lines, totals, fmt }: DraftPreviewProps) {
182
+ const items = lines.filter((l) => l.kind === 'item')
183
+ return (
184
+ <article className="space-y-3 rounded border bg-background p-4 text-xs" data-slot="preview-draft">
185
+ <header className="flex flex-wrap items-baseline justify-between gap-2 border-b pb-2">
186
+ <span className="text-sm font-semibold">{title}</span>
187
+ {party && <span className="font-medium">{party}</span>}
188
+ </header>
189
+ {header.length > 0 && (
190
+ <dl className="grid grid-cols-2 gap-x-4 gap-y-1">
191
+ {header.map((h) => (
192
+ <div key={h.label} className="min-w-0">
193
+ <dt className="text-muted-foreground">{h.label}</dt>
194
+ <dd className="truncate">{h.value}</dd>
195
+ </div>
196
+ ))}
197
+ </dl>
198
+ )}
199
+ <table className="w-full tabular-nums">
200
+ <tbody>
201
+ {items.map((l) => (
202
+ <tr key={l.key} className="border-t align-top">
203
+ <td className="py-1 pr-2">{l.description}</td>
204
+ <td className="py-1 pr-2 text-right">{l.quantity}</td>
205
+ <td className="py-1 text-right">{fmt.money(computeLine(l).net)}</td>
206
+ </tr>
207
+ ))}
208
+ </tbody>
209
+ </table>
210
+ <TotalsPanel rows={totals} />
211
+ </article>
212
+ )
213
+ }