// DynamicRelation — primitivo metadata-driven que renderiza el lado N de una
// relación 1:N o N:N entre modelos. Cubre dos kinds:
// - "one_to_many": lista inline editable que cuelga del registro padre.
// - "many_to_many": multi-select sobre la tabla destino con sync a la pivot.
// La RFC completa vive en `packages/runtime-react/docs/relations.md`.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
type ColumnDef,
type Row,
type Cell,
type HeaderGroup,
type Header,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import { cn } from '@asteby/metacore-ui/lib'
import {
Button,
Skeleton,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Input,
MultiSelect,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@asteby/metacore-ui/primitives'
import { Plus, Trash2, Pencil, Search } from 'lucide-react'
import { useApi } from './api-context'
import { useMetadataCache } from './metadata-cache'
import { OptionsContext } from './options-context'
import { DynamicForm } from './dynamic-form'
import { useImageUrl } from './image-url-context'
import { useTimeZone, useCurrency } from './org-runtime-context'
import { makeDefaultGetDynamicColumns } from './dynamic-columns'
import { isColumnVisibleInLineSubtable } from './column-visibility'
import { useOptionsResolver } from './use-options-resolver'
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
import type { ApiResponse, ColumnDefinition, TableMetadata } from './types'
import {
buildCreatePayload,
buildPivotAttachPayload,
buildPivotRowIndex,
buildRelationFilterParams,
deriveRelationFormFields,
diffSelection,
extractSelectedTargetIds,
pickOptionLabel,
relationRowKey,
type DynamicRelationKind,
} from './dynamic-relation-helpers'
export type { DynamicRelationKind } from './dynamic-relation-helpers'
export {
buildCreatePayload,
buildPivotAttachPayload,
buildPivotRowIndex,
buildRelationFilterParams,
deriveRelationFormFields,
diffSelection,
extractSelectedTargetIds,
formatRelationCell,
objectLabel,
pickOptionLabel,
relationRowKey,
} from './dynamic-relation-helpers'
export interface DynamicRelationStrings {
title: string
emptyState: string
addLabel: string
editLabel: string
removeLabel: string
confirmRemoveTitle: string
confirmRemoveDescription: string
cancelLabel: string
saveLabel: string
selectPlaceholder: string
selectSearchPlaceholder: string
selectEmpty: string
/** Placeholder del buscador de la sub-tabla 1:N. */
searchPlaceholder: string
/** Pie de la sub-tabla: "{{loaded}} de {{total}}". */
countLabel: string
}
const DEFAULT_STRINGS: DynamicRelationStrings = {
title: '',
emptyState: 'No hay registros relacionados.',
addLabel: 'Agregar',
editLabel: 'Editar',
removeLabel: 'Quitar',
confirmRemoveTitle: '¿Quitar el registro?',
confirmRemoveDescription: 'Esta acción no se puede deshacer.',
cancelLabel: 'Cancelar',
saveLabel: 'Guardar',
selectPlaceholder: 'Seleccionar…',
selectSearchPlaceholder: 'Buscar…',
selectEmpty: 'Sin resultados.',
searchPlaceholder: 'Buscar…',
countLabel: '{{loaded}} de {{total}}',
}
// Tamaño de página de la sub-tabla 1:N. Antes la lista pedía el hijo COMPLETO
// (sin page/per_page), así que abrir un almacén traía todas sus existencias y
// traspasos. Se pagina de a 25 y el resto entra por scroll infinito.
const REL_PAGE_SIZE = 25
interface CommonProps {
/** id del registro padre. */
parentId: string | number
/**
* Filtros estáticos extra (igualdad) aplicados ADEMÁS del foreign-key.
* Caso polimórfico: una tabla de hijos compartida (attachments,
* addresses) scopeada por `foreign_key=owner_id` Y `owner_model=Customer`.
* Cada entrada se thread-ea como `f_
=eq:` junto al FK en la query
* de la lista hija. Aditivo: sin filters el comportamiento es idéntico.
*/
filters?: Record
/** Hidden columns; el FK siempre se oculta automáticamente. */
hiddenColumns?: string[]
/**
* Contexto de sub-tabla de líneas dentro del MODAL de vista de un registro
* padre. Cuando es true se aplican las reglas de {@link isColumnVisibleInLineSubtable}
* — se ocultan por defecto las columnas de auditoría/sistema (created_by,
* timestamps, organization_id) y las scopeadas a `visibility: "table"`, que
* son ruido redundante bajo el registro padre.
*
* Default false: en una página de detalle autónoma (`/m//`) el
* panel de relación conserva el comportamiento previo (solo oculta FK, scope
* y columnas `hidden`), donde esas columnas SÍ son útiles.
*/
lineSubtable?: boolean
/** Permisos visibles. Default true. */
canCreate?: boolean
canDelete?: boolean
canEdit?: boolean
/**
* Relación de solo lectura. Cuando es true fuerza canCreate/canEdit/canDelete
* = false (AND con lo que pase el host: readonly siempre gana), escondiendo el
* botón "Agregar", el ícono editar (Pencil) y el de eliminar (Trash2). Tolera
* el alias camelCase `readOnly`.
*/
readonly?: boolean
/** Alias camelCase de `readonly`. */
readOnly?: boolean
/** Strings traducibles. */
strings?: Partial
/** Wrapper className. */
className?: string
/** Callback opcional cuando la selección o la lista cambia. */
onChange?: () => void
}
export interface DynamicRelationOneToManyProps extends CommonProps {
kind: 'one_to_many'
/** Modelo hijo (lado N) cuyas filas se listan filtradas por `foreignKey == parentId`. */
model: string
/** Foreign key del lado N que apunta al padre. */
foreignKey: string
/** Endpoint override; default `/data/${model}`. */
endpoint?: string
}
export interface DynamicRelationManyToManyProps extends CommonProps {
kind: 'many_to_many'
/** Tabla pivote (`through`). FK al padre vive acá como `foreignKey`. */
through: string
/** Tabla destino (`references`) sobre la que se hace multi-select. */
references: string
/** FK del pivot al padre. */
foreignKey: string
/** FK del pivot a la tabla destino (default `${references}_id`). */
referencesKey?: string
/** Override del endpoint del pivot; default `/data/${through}`. */
pivotEndpoint?: string
/** Override del endpoint del target; default `/data/${references}`. */
referencesEndpoint?: string
/**
* Columna del target que se usa como label en el multi-select. Si no se
* pasa, se infiere de la metadata (primer columna no-id, no-hidden).
*/
displayKey?: string
}
export type DynamicRelationProps =
| DynamicRelationOneToManyProps
| DynamicRelationManyToManyProps
export function DynamicRelation(props: DynamicRelationProps) {
if (props.kind === 'many_to_many') {
return
}
return
}
function OneToManyRelation({
kind,
model,
foreignKey,
parentId,
filters,
endpoint,
hiddenColumns = [],
lineSubtable = false,
canCreate = true,
canDelete = true,
canEdit = true,
readonly,
readOnly,
strings,
className,
onChange,
}: DynamicRelationOneToManyProps) {
// Read-only relation always wins over host-passed perms (AND). Tolerates the
// camelCase alias the same way lock_rows/lockRows does.
const isReadonly = readonly === true || readOnly === true
if (isReadonly) {
canCreate = false
canDelete = false
canEdit = false
}
const api = useApi()
const getImageUrl = useImageUrl()
const timeZone = useTimeZone()
const currency = useCurrency()
const { i18n } = useTranslation()
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
const cachedMeta = getMetadata(model)
const labels = { ...DEFAULT_STRINGS, ...(strings || {}) }
const [metadata, setMetadata] = useState(cachedMeta || null)
// Options for the ref/relation columns so their cells render the record's
// NAME (+ SKU subtitle) instead of the raw uuid. The main
// preloads these into OptionsContext; a sub-table skipped it, so a `ref`
// column (e.g. a purchase order line's Product) showed the bare uuid.
const [optionsMap, setOptionsMap] = useState