diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 55935fba4..b10a951b8 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -390,91 +390,6 @@ body[data-scrolled='true'] .navbar { display: none !important; } -/* Keep invoice mode and related fields together when requirements change. */ -[id='operation/createInvoice'] tbody:has(> tr > td[title='status'] + td select) { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - - > tr { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / -1; - } - - > tr > td { - width: auto; - } - - > tr > td[colspan] { - grid-column: 1 / -1; - } - - > tr:has(> td[colspan]) { - animation: invoice-field-details-enter 180ms ease-out; - transform-origin: top; - } - - > tr:has(> td[title='status']), - > tr:has(> td[title='status']) + tr:has(> td[colspan]) { - order: -11; - } - - > tr:has(> td[title='status']) { - > td + td > div { - display: flex; - flex-direction: column; - - > div:has(> select) { - order: -1; - margin-bottom: 0.5rem; - } - } - } - - > tr:has(> td[title='type']), - > tr:has(> td[title='type']) + tr:has(> td[colspan]) { order: -10; } - > tr:has(> td[title='customer']), - > tr:has(> td[title='customer']) + tr:has(> td[colspan]) { order: -9; } - > tr:has(> td[title='third_party']), - > tr:has(> td[title='third_party']) + tr:has(> td[colspan]) { order: -8; } - > tr:has(> td:is([title='items'], [title='complements'])), - > tr:has(> td:is([title='items'], [title='complements'])) + tr:has(> td[colspan]) { order: -7; } - > tr:has(> td[title='use']), - > tr:has(> td[title='use']) + tr:has(> td[colspan]) { order: -6; } - > tr:has(> td:is([title='payment_form'], [title='payment_method'], [title='conditions'])), - > tr:has(> td:is([title='payment_form'], [title='payment_method'], [title='conditions'])) + tr:has(> td[colspan]) { order: -5; } - > tr:has(> td:is([title='currency'], [title='exchange'])), - > tr:has(> td:is([title='currency'], [title='exchange'])) + tr:has(> td[colspan]) { order: -4; } - > tr:has(> td:is([title='date'], [title='folio_number'], [title='series'])), - > tr:has(> td:is([title='date'], [title='folio_number'], [title='series'])) + tr:has(> td[colspan]) { order: -3; } - > tr:has(> td:is([title='related_documents'], [title='global'], [title='export'])), - > tr:has(> td:is([title='related_documents'], [title='global'], [title='export'])) + tr:has(> td[colspan]) { order: 1; } - > tr:has(> td:is([title='pdf_custom_section'], [title='addenda'], [title='namespaces'], [title='pdf_options'])), - > tr:has(> td:is([title='pdf_custom_section'], [title='addenda'], [title='namespaces'], [title='pdf_options'])) + tr:has(> td[colspan]) { order: 2; } - - @media (max-width: 50rem) { - grid-template-columns: minmax(0, 1fr); - } -} - -@keyframes invoice-field-details-enter { - from { - opacity: 0; - transform: translateY(-0.4rem); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (prefers-reduced-motion: reduce) { - [id='operation/createInvoice'] tbody:has(> tr > td[title='status'] + td select) > tr:has(> td[colspan]) { - animation: none; - } -} - html[data-theme='dark'] .redocusaurus button[aria-expanded='true'] { border-color: transparent !important; } @@ -532,3 +447,10 @@ html[data-theme='dark'] .redocusaurus div[aria-hidden='false'] [tabindex='0'] { [id='operation/createInvoice'] tr:has(> td[title='customer']) + tr > td > div > div > div > span:first-child { display: none; } + +html[data-theme='dark'] .redocusaurus select, +html[data-theme='dark'] .redocusaurus select option { + color: var(--ifm-font-color-base); + background-color: var(--facturapi-surface-bg); + color-scheme: dark; +} diff --git a/website/src/pages/api.tsx b/website/src/pages/api.tsx index 90e65d814..d93a741b9 100644 --- a/website/src/pages/api.tsx +++ b/website/src/pages/api.tsx @@ -35,26 +35,118 @@ function CustomPage() { ? 'Datos del cliente o ID de un cliente registrado.' : 'Customer details or the ID of a registered customer.', }; - // ReDoc needs named discriminator variants to render a status selector. - // Keep this presentation hint out of the contract: status may be omitted. + spec.components.schemas.InvoiceDraftCommonInput.allOf[1].properties.customer = { + ...spec.components.schemas.InvoiceCommonInput.allOf[0].properties.customer, + oneOf: [ + ...spec.components.schemas.InvoiceCustomerInput.oneOf, + { + type: 'null', + title: locale === 'es' ? 'Sin cliente' : 'No customer', + }, + ], + }; + for (const customer of [ + spec.components.schemas.ReceiptInput.allOf[0].properties.customer, + spec.components.schemas.ReceiptAssignCustomerInput.properties.customer, + spec.components.schemas.InvoiceReceiptInput.allOf[0].properties.customer, + spec.components.schemas.ToInvoiceInput.properties.customer, + spec.components.schemas.ToInvoicePreviewInput.properties.customer, + spec.components.schemas.RetentionUpdateInput.properties.customer, + ]) { + customer.oneOf = customer.oneOf.map((variant: {$ref?: string; type?: string}) => { + if (variant.$ref === '#/components/schemas/CustomerCreateInput') { + return spec.components.schemas.InvoiceCustomerInput.oneOf[0]; + } + return { + ...variant, + title: variant.type === 'null' + ? (locale === 'es' ? 'Sin cliente' : 'No customer') + : (locale === 'es' ? 'Cliente registrado' : 'Registered customer'), + }; + }); + } + for (const name of ['InvoiceItemInput', 'InvoiceItemEgresoInput', 'InvoiceItemTrasladoInput']) { + const product = spec.components.schemas[name].properties.product; + product.description = locale === 'es' + ? 'Datos del producto o servicio, o ID de un producto registrado.' + : 'Product or service details, or the ID of a registered product.'; + product.oneOf = product.oneOf.map((variant: {$ref?: string; type?: string}) => ({ + ...variant, + title: variant.type === 'string' + ? (locale === 'es' ? 'Producto registrado' : 'Registered product') + : (locale === 'es' ? 'Datos del producto' : 'Product details'), + })); + } + // Native oneOf tabs keep the viewing mode separate from request fields. spec.components.schemas.InvoiceCreateInput.oneOf = spec.components.schemas.InvoiceCreateInput.oneOf.map( - (invoiceType: {oneOf: {allOf: {properties: {status: {const: string}}}[]}[]}, typeIndex: number) => { - const mapping: Record = {}; - return { - ...invoiceType, - oneOf: invoiceType.oneOf.map((variant) => { - const status = variant.allOf.at(-1)!.properties.status.const; - const name = `InvoiceCreateDisplay${typeIndex}${status}`; - spec.components.schemas[name] = variant; - mapping[status] = `#/components/schemas/${name}`; - return {$ref: mapping[status]}; - }), - discriminator: {propertyName: 'status', mapping}, - }; - }, + (invoiceType: {oneOf: {title: string}[]}) => ({ + ...invoiceType, + oneOf: invoiceType.oneOf.map((variant) => ({ + ...variant, + title: variant.title === 'draft' + ? (locale === 'es' ? 'Borrador' : 'Draft') + : (locale === 'es' ? 'Timbrar' : 'Issue'), + })), + }), ); - return {...specData, spec}; + for (const [name, schema] of Object.entries<{allOf?: {properties?: {type?: {description: string}}}[]; properties?: {type?: {description: string}}}>(spec.components.schemas)) { + if (!name.startsWith('Invoice') || !name.endsWith('Input')) continue; + for (const part of [schema, ...(schema.allOf || [])]) { + if (part.properties?.type) { + part.properties.type.description = locale === 'es' + ? 'Código del tipo de factura seleccionado arriba. Selecciona otro tipo para consultar sus campos y código correspondiente.' + : 'Code for the invoice type selected above. Select another type to see its fields and corresponding code.'; + } + } + } + type OrderedSchema = { + $ref?: string; + allOf?: OrderedSchema[]; + properties?: Record; + }; + function propertyNames(schema: OrderedSchema): string[] { + if (schema.$ref) { + return propertyNames(spec.components.schemas[schema.$ref.split('/').pop()!]); + } + return [ + ...Object.keys(schema.properties || {}), + ...(schema.allOf || []).flatMap(propertyNames), + ]; + } + const orderedInputs: [OrderedSchema[], string[]][] = [ + [ + spec.components.schemas.InvoiceCreateInput.oneOf.flatMap( + (invoiceType: {oneOf: OrderedSchema[]}) => invoiceType.oneOf, + ), + ['customer', 'items', 'payment_form', 'type', 'status', 'payment_method', + 'use', 'currency', 'exchange', 'conditions', 'date', 'folio_number', + 'series', 'address', 'third_party', 'related_documents', 'global', + 'export', 'complements', 'external_id', 'idempotency_key', + 'pdf_custom_section', 'addenda', 'namespaces', 'pdf_options'], + ], + [ + spec.components.schemas.RetentionInput.oneOf, + ['customer', 'cve_retenc', 'periodo', 'totales', 'status', 'fecha_exp', + 'desc_retenc', 'folio_int', 'external_id', 'idempotency_key', + 'complements', 'pdf_custom_section', 'addenda', 'namespaces'], + ], + ]; + for (const [variants, order] of orderedInputs) { + for (const variant of variants) { + const names = new Set(propertyNames(variant)); + // Empty schemas establish insertion order; allOf keeps every field constraint. + variant.properties = Object.fromEntries([ + ...order.filter((name) => names.has(name)), + ...[...names].filter((name) => !order.includes(name)), + ].map((name) => [name, variant.properties?.[name] || {}])); + } + } + return { + ...specData, + spec, + optionsOverrides: {sortRequiredPropsFirst: false}, + }; }, [specData, locale]); return (