@asteby/metacore-runtime-react 49.3.1 → 49.4.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 +6 -0
- package/dist/business/document-editor-panels.d.ts +2 -1
- package/dist/business/document-editor-panels.d.ts.map +1 -1
- package/dist/business/document-editor-panels.js +6 -6
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +23 -9
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-items-editor.d.ts +6 -1
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +109 -83
- package/dist/business/line-product-cell.d.ts +32 -0
- package/dist/business/line-product-cell.d.ts.map +1 -0
- package/dist/business/line-product-cell.js +100 -0
- package/dist/dynamic-select-field.d.ts +2 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +49 -19
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +13 -11
- package/dist/field-grid.d.ts +7 -1
- package/dist/field-grid.d.ts.map +1 -1
- package/dist/field-grid.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/record-picker-actions.d.ts +27 -0
- package/dist/record-picker-actions.d.ts.map +1 -0
- package/dist/record-picker-actions.js +32 -0
- package/package.json +1 -1
- package/src/__tests__/document-editor-inline-selects.test.tsx +202 -0
- package/src/business/document-editor-panels.tsx +13 -10
- package/src/business/document-editor.tsx +72 -20
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +229 -174
- package/src/business/line-product-cell.tsx +219 -0
- package/src/dynamic-select-field.tsx +51 -22
- package/src/entity-select.tsx +17 -39
- package/src/field-grid.tsx +15 -1
- package/src/index.ts +2 -0
- package/src/record-picker-actions.tsx +81 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DocumentEditor — selects «pro» (feedback del dueño 2026-10-06):
|
|
4
|
+
// 1. El buscador de producto vive DENTRO de la celda «Descripción» del
|
|
5
|
+
// renglón (no una barra a todo lo ancho) y siempre queda un renglón vacío
|
|
6
|
+
// al final; Enter elige y pasa el foco a «Cant.».
|
|
7
|
+
// 2. La lista de resultados se monta en un portal: fuera del contenedor con
|
|
8
|
+
// overflow de la tabla, así no la recorta ni la tapa nada.
|
|
9
|
+
// 3. Los dynamic_select con `ref` llevan crear/editar integrados al trigger:
|
|
10
|
+
// sin valor «+» abre el alta; con valor el lápiz abre la edición de ESE
|
|
11
|
+
// registro (y solo entonces). «Crear …» también al pie de la lista.
|
|
12
|
+
import { useState } from 'react'
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
14
|
+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
15
|
+
|
|
16
|
+
vi.mock('react-i18next', () => ({
|
|
17
|
+
useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import { DocumentLinesGrid } from '../business/line-items-editor'
|
|
21
|
+
import { makeLine, type LineItem } from '../business/line-items'
|
|
22
|
+
import type { ProductResult } from '../business/product-search'
|
|
23
|
+
import { DynamicSelectField } from '../dynamic-select-field'
|
|
24
|
+
import { EntitySelect } from '../entity-select'
|
|
25
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
26
|
+
import type { ActionFieldDef } from '../types'
|
|
27
|
+
|
|
28
|
+
afterEach(cleanup)
|
|
29
|
+
|
|
30
|
+
const tire: ProductResult = { id: 'p1', name: 'Llanta 205/55R16', sku: 'LL-205', price: 1500, tax_rate: 0.16 }
|
|
31
|
+
const valve: ProductResult = { id: 'p2', name: 'Válvula', sku: 'VAL', price: 40, tax_rate: 0.16 }
|
|
32
|
+
|
|
33
|
+
function Grid({ initial = [], search }: { initial?: LineItem[]; search: (q: any, s: AbortSignal) => Promise<ProductResult[]> }) {
|
|
34
|
+
const [lines, setLines] = useState(initial)
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<DocumentLinesGrid value={lines} onChange={setLines} search={search} columns={['discount', 'tax']} currency="MXN" />
|
|
38
|
+
<output data-testid="state">{JSON.stringify(lines)}</output>
|
|
39
|
+
</>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
const state = () => JSON.parse(screen.getByTestId('state').textContent ?? '[]') as LineItem[]
|
|
43
|
+
|
|
44
|
+
describe('DocumentLinesGrid — select de producto en la celda', () => {
|
|
45
|
+
it('el buscador vive en la celda «Descripción» del renglón vacío, no en una fila a todo lo ancho', () => {
|
|
46
|
+
render(<Grid search={vi.fn(async () => [tire])} />)
|
|
47
|
+
const input = screen.getByRole('combobox', { name: 'Buscar producto' })
|
|
48
|
+
const td = input.closest('td')!
|
|
49
|
+
expect(td).toBeTruthy()
|
|
50
|
+
// Ocupa solo su celda: no hay colSpan sobre toda la fila.
|
|
51
|
+
expect(td.colSpan).toBe(1)
|
|
52
|
+
expect(td.closest('tr')!.getAttribute('data-slot')).toBe('line-draft-row')
|
|
53
|
+
expect(td.closest('tr')!.children.length).toBeGreaterThan(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('la lista sale en un portal fuera del contenedor con overflow y Enter elige y pasa a «Cant.»', async () => {
|
|
57
|
+
render(<Grid search={vi.fn(async () => [tire, valve])} />)
|
|
58
|
+
const input = screen.getByRole('combobox', { name: 'Buscar producto' })
|
|
59
|
+
input.focus()
|
|
60
|
+
fireEvent.change(input, { target: { value: 'llan' } })
|
|
61
|
+
const listbox = await waitFor(() => screen.getByRole('listbox'), { timeout: 2000 })
|
|
62
|
+
await waitFor(() => expect(within(listbox).getAllByRole('option')).toHaveLength(2))
|
|
63
|
+
|
|
64
|
+
const scroller = document.querySelector('[data-slot="line-items-editor"] .overflow-x-auto')!
|
|
65
|
+
expect(scroller.contains(listbox)).toBe(false)
|
|
66
|
+
expect(document.querySelector('[data-slot="line-items-editor"]')!.contains(listbox)).toBe(false)
|
|
67
|
+
expect(listbox.closest('[data-slot="line-product-results"]')).toBeTruthy()
|
|
68
|
+
|
|
69
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
|
70
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
71
|
+
expect(state()).toHaveLength(1)
|
|
72
|
+
expect(state()[0]).toMatchObject({ product_id: 'p2', description: 'Válvula', unit_price: 40, tax_rate: 0.16, quantity: 1 })
|
|
73
|
+
await waitFor(() => expect((document.activeElement as HTMLElement | null)?.getAttribute('data-cell')).toBe('quantity'))
|
|
74
|
+
const row = (document.activeElement as HTMLElement).closest('tr')!
|
|
75
|
+
expect(row.getAttribute('data-line-key')).toBe(state()[0]!.key)
|
|
76
|
+
// Sigue habiendo un renglón vacío al final, listo para el siguiente.
|
|
77
|
+
expect((screen.getByRole('combobox', { name: 'Buscar producto' }) as HTMLInputElement).value).toBe('')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('Enter con texto sin coincidencias agrega un renglón libre con esa descripción', async () => {
|
|
81
|
+
render(<Grid search={vi.fn(async () => [])} />)
|
|
82
|
+
const input = screen.getByRole('combobox', { name: 'Buscar producto' })
|
|
83
|
+
fireEvent.change(input, { target: { value: 'Mano de obra' } })
|
|
84
|
+
await waitFor(() => screen.getByText(/Sin coincidencias/), { timeout: 2000 })
|
|
85
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
86
|
+
expect(state()).toHaveLength(1)
|
|
87
|
+
expect(state()[0]!.description).toBe('Mano de obra')
|
|
88
|
+
expect(state()[0]!.product_id).toBeUndefined()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('en un renglón libre, elegir un producto llena ESE renglón (misma clave)', async () => {
|
|
92
|
+
const free = makeLine({ description: '' })
|
|
93
|
+
render(<Grid initial={[free]} search={vi.fn(async () => [tire])} />)
|
|
94
|
+
const cell = screen.getByRole('combobox', { name: 'Descripción' })
|
|
95
|
+
expect(cell.closest('tr')!.getAttribute('data-line-key')).toBe(free.key)
|
|
96
|
+
fireEvent.change(cell, { target: { value: '205' } })
|
|
97
|
+
const option = await waitFor(() => screen.getByRole('option', { name: /Llanta 205/ }), { timeout: 2000 })
|
|
98
|
+
fireEvent.click(within(option).getByRole('button'))
|
|
99
|
+
expect(state()).toHaveLength(1)
|
|
100
|
+
expect(state()[0]).toMatchObject({ key: free.key, product_id: 'p1', unit_price: 1500, sku: 'LL-205' })
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('DynamicSelectField — crear/editar integrados', () => {
|
|
105
|
+
const field: ActionFieldDef = { key: 'customer_id', label: 'Cliente', type: 'dynamic_select', ref: 'customers.Customer' }
|
|
106
|
+
const api = () =>
|
|
107
|
+
({
|
|
108
|
+
get: vi.fn(async () => ({ data: { success: true, data: [{ id: 'c1', label: 'Transportes del Bajío' }] } })),
|
|
109
|
+
post: vi.fn(),
|
|
110
|
+
}) as unknown as ApiClient
|
|
111
|
+
|
|
112
|
+
const capture = (name: string) => {
|
|
113
|
+
const seen: any[] = []
|
|
114
|
+
const fn = (e: Event) => seen.push((e as CustomEvent).detail)
|
|
115
|
+
window.addEventListener(name, fn)
|
|
116
|
+
return { seen, off: () => window.removeEventListener(name, fn) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it('sin valor: «+» unido al trigger abre el alta del modelo ref; no hay lápiz', () => {
|
|
120
|
+
const create = capture('metacore:create-record')
|
|
121
|
+
render(
|
|
122
|
+
<ApiProvider client={api()}>
|
|
123
|
+
<DynamicSelectField field={field} value="" onChange={() => {}} />
|
|
124
|
+
</ApiProvider>,
|
|
125
|
+
)
|
|
126
|
+
expect(screen.queryByRole('button', { name: 'Editar Cliente' })).toBeNull()
|
|
127
|
+
const btn = screen.getByRole('button', { name: 'Crear Cliente' })
|
|
128
|
+
expect(btn.getAttribute('data-action')).toBe('create')
|
|
129
|
+
// Unido: comparte borde con el trigger (sin hueco entre ambos).
|
|
130
|
+
expect(screen.getByRole('combobox').className).toContain('rounded-r-none')
|
|
131
|
+
fireEvent.click(btn)
|
|
132
|
+
expect(create.seen).toHaveLength(1)
|
|
133
|
+
expect(create.seen[0].model).toBe('customers.Customer')
|
|
134
|
+
create.off()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('con valor: el lápiz abre la edición de ESE registro; no hay «+»', () => {
|
|
138
|
+
const edit = capture('metacore:edit-record')
|
|
139
|
+
const onChange = vi.fn()
|
|
140
|
+
render(
|
|
141
|
+
<ApiProvider client={api()}>
|
|
142
|
+
<DynamicSelectField field={field} value="c1" onChange={onChange} seedOption={{ id: 'c1', value: 'c1', label: 'Transportes del Bajío' }} />
|
|
143
|
+
</ApiProvider>,
|
|
144
|
+
)
|
|
145
|
+
expect(screen.queryByRole('button', { name: 'Crear Cliente' })).toBeNull()
|
|
146
|
+
fireEvent.click(screen.getByRole('button', { name: 'Editar Cliente' }))
|
|
147
|
+
expect(edit.seen).toHaveLength(1)
|
|
148
|
+
expect(edit.seen[0]).toMatchObject({ model: 'customers.Customer', recordId: 'c1' })
|
|
149
|
+
// Al guardar la edición, el trigger refresca la etiqueta.
|
|
150
|
+
act(() => edit.seen[0].onSaved({ id: 'c1', name: 'Transportes del Bajío SA' }))
|
|
151
|
+
expect(screen.getByRole('combobox').textContent).toContain('Transportes del Bajío SA')
|
|
152
|
+
edit.off()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('«Crear …» al pie de la lista, y la lista se monta en un portal', async () => {
|
|
156
|
+
const create = capture('metacore:create-record')
|
|
157
|
+
const { container } = render(
|
|
158
|
+
<ApiProvider client={api()}>
|
|
159
|
+
<DynamicSelectField field={field} value="" onChange={() => {}} />
|
|
160
|
+
</ApiProvider>,
|
|
161
|
+
)
|
|
162
|
+
await act(async () => {
|
|
163
|
+
screen.getByRole('combobox').click()
|
|
164
|
+
})
|
|
165
|
+
await waitFor(() => expect(screen.getByText('Transportes del Bajío')).toBeTruthy())
|
|
166
|
+
const footer = document.querySelector('[data-slot="picker-create"]') as HTMLElement
|
|
167
|
+
expect(footer).toBeTruthy()
|
|
168
|
+
expect(container.contains(footer)).toBe(false)
|
|
169
|
+
fireEvent.click(footer)
|
|
170
|
+
expect(create.seen).toHaveLength(1)
|
|
171
|
+
create.off()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('hideCreate quita las dos acciones', () => {
|
|
175
|
+
render(
|
|
176
|
+
<ApiProvider client={api()}>
|
|
177
|
+
<DynamicSelectField field={field} value="c1" onChange={() => {}} hideCreate />
|
|
178
|
+
</ApiProvider>,
|
|
179
|
+
)
|
|
180
|
+
expect(document.querySelector('[data-slot="record-picker-action"]')).toBeNull()
|
|
181
|
+
expect(screen.getByRole('combobox').className).not.toContain('rounded-r-none')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('EntitySelect — mismo primitivo de acciones', () => {
|
|
186
|
+
const base = { model: 'Supplier', fetcher: async () => [], onSelect: () => {}, canCreate: true, canEdit: true }
|
|
187
|
+
|
|
188
|
+
it('sin valor ofrece crear; con valor, editar', () => {
|
|
189
|
+
const { rerender } = render(
|
|
190
|
+
<ApiProvider client={{ get: vi.fn(), post: vi.fn() } as unknown as ApiClient}>
|
|
191
|
+
<EntitySelect {...base} value={null} label={null} />
|
|
192
|
+
</ApiProvider>,
|
|
193
|
+
)
|
|
194
|
+
expect(document.querySelector('[data-slot="record-picker-action"]')!.getAttribute('data-action')).toBe('create')
|
|
195
|
+
rerender(
|
|
196
|
+
<ApiProvider client={{ get: vi.fn(), post: vi.fn() } as unknown as ApiClient}>
|
|
197
|
+
<EntitySelect {...base} value="s1" label="Llantera Norte" />
|
|
198
|
+
</ApiProvider>,
|
|
199
|
+
)
|
|
200
|
+
expect(document.querySelector('[data-slot="record-picker-action"]')!.getAttribute('data-action')).toBe('edit')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -14,8 +14,8 @@ export function EditorSection({ title, hint, children, slot }: { title?: ReactNo
|
|
|
14
14
|
return (
|
|
15
15
|
<section className="space-y-3" data-slot={slot}>
|
|
16
16
|
{(title || hint) && (
|
|
17
|
-
<header className="flex items-baseline justify-between gap-3">
|
|
18
|
-
{title && <h3 className="text-sm font-semibold">{title}</h3>}
|
|
17
|
+
<header className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
|
18
|
+
{title && <h3 className="whitespace-nowrap text-sm font-semibold">{title}</h3>}
|
|
19
19
|
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
|
|
20
20
|
</header>
|
|
21
21
|
)}
|
|
@@ -49,13 +49,13 @@ export function CollapsibleSection({ title, summary, open, defaultOpen = false,
|
|
|
49
49
|
onOpenChange?.(!isOpen)
|
|
50
50
|
}
|
|
51
51
|
return (
|
|
52
|
-
<section className="rounded-
|
|
52
|
+
<section className="rounded-xl border border-dashed" data-slot={slot} data-state={isOpen ? 'open' : 'closed'}>
|
|
53
53
|
<button
|
|
54
54
|
type="button"
|
|
55
55
|
aria-expanded={isOpen}
|
|
56
56
|
aria-controls={id}
|
|
57
57
|
onClick={toggle}
|
|
58
|
-
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm"
|
|
58
|
+
className="flex w-full items-center justify-between gap-3 rounded-xl px-4 py-3 text-left text-sm transition-colors hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
59
59
|
>
|
|
60
60
|
<span className="font-medium">{title}</span>
|
|
61
61
|
<span className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
|
@@ -81,7 +81,7 @@ export interface PartyCardProps {
|
|
|
81
81
|
/** Tarjeta compacta de la contraparte: nombre, 3-6 datos clave y estado de crédito. */
|
|
82
82
|
export function PartyCard({ name, rows, credit, fmt, children }: PartyCardProps) {
|
|
83
83
|
return (
|
|
84
|
-
<div className="rounded-
|
|
84
|
+
<div className="rounded-lg bg-muted/40 px-4 py-3 text-sm" data-slot="party-card">
|
|
85
85
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
86
86
|
{name && <span className="font-medium">{name}</span>}
|
|
87
87
|
{credit && (
|
|
@@ -116,11 +116,14 @@ export interface TotalsRow {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/** Totales alineados a la derecha; la última fila con `emphasis` es el total. */
|
|
119
|
-
export function TotalsPanel({ rows }: { rows: TotalsRow[] }) {
|
|
119
|
+
export function TotalsPanel({ rows, muted = false }: { rows: TotalsRow[]; muted?: boolean }) {
|
|
120
120
|
return (
|
|
121
|
-
<dl className=
|
|
122
|
-
{rows.map((r) => (
|
|
123
|
-
<div
|
|
121
|
+
<dl className={`ml-auto w-full max-w-xs space-y-1.5 text-sm tabular-nums ${muted ? 'text-muted-foreground' : ''}`} data-slot="totals">
|
|
122
|
+
{rows.map((r, i) => (
|
|
123
|
+
<div
|
|
124
|
+
key={r.label}
|
|
125
|
+
className={`flex items-baseline justify-between gap-6 ${r.emphasis ? `${i > 0 ? 'border-t pt-2.5' : ''} text-lg font-semibold` : ''}`}
|
|
126
|
+
>
|
|
124
127
|
<dt className={r.emphasis ? '' : 'text-muted-foreground'}>{r.label}</dt>
|
|
125
128
|
<dd>{r.value}</dd>
|
|
126
129
|
</div>
|
|
@@ -137,7 +140,7 @@ export function ValidationChecklist({ issues, title = 'Revisa antes de guardar'
|
|
|
137
140
|
<p className="mb-1 text-xs font-medium text-muted-foreground">{title}</p>
|
|
138
141
|
<ul className="space-y-1 text-sm">
|
|
139
142
|
{issues.map((i, k) => (
|
|
140
|
-
<li key={`${i.field ?? ''}:${k}`} className={`flex items-start gap-2 ${i.severity === 'error' ? 'text-destructive' : 'text-amber-700'}`}>
|
|
143
|
+
<li key={`${i.field ?? ''}:${k}`} className={`flex items-start gap-2 ${i.severity === 'error' ? 'text-destructive' : 'text-amber-700 dark:text-amber-400'}`}>
|
|
141
144
|
{i.severity === 'error' ? <XCircle className="mt-0.5 size-4 shrink-0" aria-hidden /> : <AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />}
|
|
142
145
|
{i.message}
|
|
143
146
|
</li>
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
21
21
|
import { useTranslation } from 'react-i18next'
|
|
22
22
|
import { Button } from '@asteby/metacore-ui/primitives'
|
|
23
|
-
import { Loader2 } from 'lucide-react'
|
|
23
|
+
import { Loader2, ReceiptText } from 'lucide-react'
|
|
24
24
|
import { toast } from 'sonner'
|
|
25
25
|
import { useApi } from '../api-context'
|
|
26
26
|
import { useOrgTaxRate, useTimeZone } from '../org-runtime-context'
|
|
@@ -584,10 +584,10 @@ export function DocumentEditor({
|
|
|
584
584
|
}
|
|
585
585
|
|
|
586
586
|
const fieldRecord = useMemo(() => seedRecord(seeds, record), [seeds, record])
|
|
587
|
-
const fieldCell = (field: ActionFieldDef, fullWidth = false) =>
|
|
587
|
+
const fieldCell = (field: ActionFieldDef, fullWidth = false, className?: string) =>
|
|
588
588
|
visible(field) ? (
|
|
589
|
-
<FieldCell key={field.key} fullWidth={fullWidth}>
|
|
590
|
-
<FieldLabel htmlFor={field.key} required={field.required}>
|
|
589
|
+
<FieldCell key={field.key} fullWidth={fullWidth} className={className}>
|
|
590
|
+
<FieldLabel htmlFor={field.key} required={field.required} tone="sentence">
|
|
591
591
|
{tl(field.label)}
|
|
592
592
|
</FieldLabel>
|
|
593
593
|
{renderField(withFriendlyOptions(gatedOptions(field, header)), header[field.key], (v: any) => updateField(field.key, v), header, fieldRecord, fieldErrors)}
|
|
@@ -641,10 +641,14 @@ export function DocumentEditor({
|
|
|
641
641
|
|
|
642
642
|
const essentials = (
|
|
643
643
|
<EditorSection slot="editor-essentials">
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
644
|
+
{/* Contraparte + fechas + condiciones en un bloque: tres columnas cuando
|
|
645
|
+
el contenedor lo permite (la contraparte ocupa dos), una en móvil. */}
|
|
646
|
+
<div className="@container rounded-xl border bg-card/40 p-4">
|
|
647
|
+
<FieldGrid className="gap-x-4 @2xl:grid-cols-3">
|
|
648
|
+
{groups.party && fieldCell(groups.party, false, '@2xl:col-span-2')}
|
|
649
|
+
{groups.essential.map((f) => fieldCell(f))}
|
|
650
|
+
</FieldGrid>
|
|
651
|
+
</div>
|
|
648
652
|
{party && (
|
|
649
653
|
<PartyCard name={party.name ?? party.legal_name} rows={summaryRows} credit={credit} fmt={fmt}>
|
|
650
654
|
{partyContribs.map(({ id, component: C }) => (
|
|
@@ -659,14 +663,18 @@ export function DocumentEditor({
|
|
|
659
663
|
<EditorSection
|
|
660
664
|
slot="load-from-source"
|
|
661
665
|
title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
|
|
662
|
-
hint={
|
|
666
|
+
hint={
|
|
667
|
+
busy === 'source'
|
|
668
|
+
? t('documentEditor.loading_source', { defaultValue: 'Cargando…' })
|
|
669
|
+
: sourceNote ?? (sourceId ? undefined : t('documentEditor.load_from_hint', { defaultValue: 'Opcional · precarga contraparte y renglones' }))
|
|
670
|
+
}
|
|
663
671
|
>
|
|
664
672
|
<div className="flex flex-wrap items-center gap-2">
|
|
665
673
|
{sources.length > 1 && (
|
|
666
674
|
<div
|
|
667
675
|
role="radiogroup"
|
|
668
676
|
aria-label={t('documentEditor.source_kind', { defaultValue: 'Documento de origen' })}
|
|
669
|
-
className="inline-flex
|
|
677
|
+
className="inline-flex h-9 shrink-0 items-center gap-0.5 rounded-md border bg-muted/40 p-0.5"
|
|
670
678
|
>
|
|
671
679
|
{sources.map((s) => (
|
|
672
680
|
<button
|
|
@@ -679,8 +687,10 @@ export function DocumentEditor({
|
|
|
679
687
|
setSourceKey(s.key)
|
|
680
688
|
setSourceId('')
|
|
681
689
|
}}
|
|
682
|
-
className={`rounded px-3
|
|
683
|
-
s.key === sourceKey
|
|
690
|
+
className={`h-full rounded-sm px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 ${
|
|
691
|
+
s.key === sourceKey
|
|
692
|
+
? 'bg-background font-medium text-foreground shadow-xs dark:bg-input/60'
|
|
693
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
684
694
|
}`}
|
|
685
695
|
>
|
|
686
696
|
{tl(s.label)}
|
|
@@ -688,6 +698,14 @@ export function DocumentEditor({
|
|
|
688
698
|
))}
|
|
689
699
|
</div>
|
|
690
700
|
)}
|
|
701
|
+
{!activeSource && (
|
|
702
|
+
<div
|
|
703
|
+
className="flex h-9 min-w-60 flex-1 items-center rounded-md border border-dashed px-3 text-sm text-muted-foreground"
|
|
704
|
+
data-slot="source-picker-empty"
|
|
705
|
+
>
|
|
706
|
+
{t('documentEditor.pick_source_kind', { defaultValue: 'Elige el tipo de documento para buscarlo' })}
|
|
707
|
+
</div>
|
|
708
|
+
)}
|
|
691
709
|
{activeSource && (
|
|
692
710
|
<SourcePicker
|
|
693
711
|
key={activeSource.key}
|
|
@@ -707,7 +725,19 @@ export function DocumentEditor({
|
|
|
707
725
|
</EditorSection>
|
|
708
726
|
)
|
|
709
727
|
|
|
710
|
-
|
|
728
|
+
// Sin renglones todavía: un estado vacío amable en vez de $0.00 en cada fila.
|
|
729
|
+
const noLines = !isAllocation && !!lineCfg && lines.length === 0
|
|
730
|
+
const totalsPanel = noLines ? (
|
|
731
|
+
<TotalsPanel rows={totalsRows.filter((r) => r.emphasis)} muted />
|
|
732
|
+
) : (
|
|
733
|
+
<TotalsPanel rows={totalsRows} />
|
|
734
|
+
)
|
|
735
|
+
const totalsEmpty = noLines && (
|
|
736
|
+
<div className="flex items-start gap-3 text-sm text-muted-foreground" data-slot="totals-empty">
|
|
737
|
+
<ReceiptText className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
738
|
+
<p>{t('documentEditor.totals_empty', { defaultValue: 'Aún no hay renglones. Busca un producto en la tabla para ver el desglose.' })}</p>
|
|
739
|
+
</div>
|
|
740
|
+
)
|
|
711
741
|
|
|
712
742
|
const linesSection = lineCfg && (
|
|
713
743
|
<EditorSection
|
|
@@ -751,6 +781,7 @@ export function DocumentEditor({
|
|
|
751
781
|
policy={kind === 'credit' ? { allowZeroQuantity: linesFromSourceDoc } : undefined}
|
|
752
782
|
search={search}
|
|
753
783
|
currency={currency}
|
|
784
|
+
showTotals={false}
|
|
754
785
|
/>
|
|
755
786
|
)}
|
|
756
787
|
{!fullscreen && totalsPanel}
|
|
@@ -817,13 +848,26 @@ export function DocumentEditor({
|
|
|
817
848
|
</CollapsibleSection>
|
|
818
849
|
) : null
|
|
819
850
|
|
|
851
|
+
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '')
|
|
820
852
|
const checklist = <ValidationChecklist issues={shownIssues} title={t('documentEditor.review', { defaultValue: 'Revisa antes de guardar' })} />
|
|
821
853
|
|
|
822
854
|
return (
|
|
823
|
-
<div
|
|
855
|
+
<div
|
|
856
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
857
|
+
data-slot="document-editor"
|
|
858
|
+
data-kind={kind}
|
|
859
|
+
data-layout={fullscreen ? 'fullscreen' : 'dialog'}
|
|
860
|
+
onKeyDown={(e) => {
|
|
861
|
+
// ⌘/Ctrl+Enter guarda desde cualquier campo del editor.
|
|
862
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && busy === null) {
|
|
863
|
+
e.preventDefault()
|
|
864
|
+
void submit()
|
|
865
|
+
}
|
|
866
|
+
}}
|
|
867
|
+
>
|
|
824
868
|
{fullscreen ? (
|
|
825
|
-
<div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-2 lg:grid lg:grid-cols-[minmax(0,1fr)
|
|
826
|
-
<div className="min-w-0 space-y-
|
|
869
|
+
<div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-2 lg:grid lg:grid-cols-[minmax(0,1fr)_320px] lg:gap-6">
|
|
870
|
+
<div className="min-w-0 space-y-6" data-slot="editor-main">
|
|
827
871
|
<FormErrorBanner message={formError} />
|
|
828
872
|
{essentials}
|
|
829
873
|
{loadFrom}
|
|
@@ -832,14 +876,17 @@ export function DocumentEditor({
|
|
|
832
876
|
{contributedSections}
|
|
833
877
|
{notes}
|
|
834
878
|
</div>
|
|
835
|
-
<aside className="mt-
|
|
836
|
-
<div className="rounded-
|
|
879
|
+
<aside className="mt-6 space-y-4 lg:sticky lg:top-0 lg:mt-0 lg:self-start" data-slot="editor-aside">
|
|
880
|
+
<div className="space-y-3 rounded-xl border bg-card/40 p-4">
|
|
881
|
+
{totalsEmpty}
|
|
882
|
+
{totalsPanel}
|
|
883
|
+
</div>
|
|
837
884
|
{checklist}
|
|
838
885
|
{previewSection}
|
|
839
886
|
</aside>
|
|
840
887
|
</div>
|
|
841
888
|
) : (
|
|
842
|
-
<div className="-mx-1 min-h-0 flex-1 space-y-
|
|
889
|
+
<div className="-mx-1 min-h-0 flex-1 space-y-6 overflow-y-auto px-1 py-2">
|
|
843
890
|
<FormErrorBanner message={formError} />
|
|
844
891
|
{essentials}
|
|
845
892
|
{loadFrom}
|
|
@@ -853,6 +900,11 @@ export function DocumentEditor({
|
|
|
853
900
|
)}
|
|
854
901
|
|
|
855
902
|
<footer className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t pt-4" data-slot="editor-actions">
|
|
903
|
+
<span className="mr-auto hidden items-center gap-1 text-xs text-muted-foreground sm:inline-flex" data-slot="editor-shortcut">
|
|
904
|
+
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-[0.7rem]">{isMac ? '⌘' : 'Ctrl'}</kbd>
|
|
905
|
+
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-[0.7rem]">Enter</kbd>
|
|
906
|
+
<span>{t('documentEditor.shortcut_save', { defaultValue: 'para guardar' })}</span>
|
|
907
|
+
</span>
|
|
856
908
|
{footerContribs.map(({ id, component: C }) => (
|
|
857
909
|
<C key={id} {...contribProps} />
|
|
858
910
|
))}
|
|
@@ -897,7 +949,7 @@ function SourcePicker({
|
|
|
897
949
|
[source, label],
|
|
898
950
|
)
|
|
899
951
|
return (
|
|
900
|
-
<div className="min-w-
|
|
952
|
+
<div className="min-w-60 flex-1" data-source={source.key}>
|
|
901
953
|
{renderField(field, value, (v: any) => onPick(v == null || v === '' ? '' : String(v)), header, seed ? { id: seed.value, label: seed.label } : undefined)}
|
|
902
954
|
</div>
|
|
903
955
|
)
|
package/src/business/index.ts
CHANGED