@asteby/metacore-runtime-react 49.1.0 → 49.2.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +26 -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 +6 -1
  6. package/dist/business/document-editor-model.d.ts.map +1 -1
  7. package/dist/business/document-editor-model.js +48 -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 +69 -24
  14. package/dist/document-form-dialog.d.ts.map +1 -1
  15. package/dist/document-form-dialog.js +8 -3
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/record-mutation-events.d.ts +12 -0
  20. package/dist/record-mutation-events.d.ts.map +1 -1
  21. package/dist/record-mutation-events.js +18 -0
  22. package/package.json +1 -1
  23. package/src/__tests__/document-editor-model.test.ts +27 -0
  24. package/src/__tests__/document-editor.test.tsx +60 -0
  25. package/src/__tests__/invoice-document-editor.test.tsx +267 -0
  26. package/src/__tests__/r5-self-label-settled.test.ts +57 -0
  27. package/src/action-modal-dispatcher.tsx +34 -5
  28. package/src/business/document-editor-model.ts +50 -7
  29. package/src/business/document-editor-panels.tsx +49 -0
  30. package/src/business/document-editor.tsx +246 -164
  31. package/src/document-form-dialog.tsx +13 -4
  32. package/src/index.ts +2 -0
  33. 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 }
@@ -267,7 +306,11 @@ export function localIssues(lines: LineItem[], opts: { requireLines?: boolean; r
267
306
  issues.push({ field: 'lines', severity: 'error', message: 'Agrega al menos un renglón.' })
268
307
  }
269
308
  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.` })
309
+ // Un renglón con producto se describe con él (el servidor lo nombra
310
+ // desde el catálogo): solo un renglón libre exige texto, como en el
311
+ // editor de renglones (validateLineItems).
312
+ if (!l.description.trim() && !l.product_id)
313
+ issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: elige un producto o escribe la descripción.` })
271
314
  if (toAmount(l.unit_price) <= 0) issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: precio en cero.` })
272
315
  if (l.max_quantity != null && toAmount(l.quantity) > toAmount(l.max_quantity)) {
273
316
  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
+ }