@asteby/metacore-runtime-react 45.2.0 → 45.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 +24 -0
- package/dist/action-modal-dispatcher.d.ts +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +115 -6
- package/dist/activity-diff.d.ts +2 -0
- package/dist/activity-diff.d.ts.map +1 -1
- package/dist/activity-diff.js +2 -2
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +8 -0
- package/dist/business/index.d.ts +2 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +2 -0
- package/dist/business/refund-destination.js +1 -1
- package/dist/business/refund.d.ts +1 -1
- package/dist/business/refund.d.ts.map +1 -1
- package/dist/business/refund.js +1 -0
- package/dist/business/return-wizard.d.ts +23 -0
- package/dist/business/return-wizard.d.ts.map +1 -0
- package/dist/business/return-wizard.js +59 -0
- package/dist/business/return.d.ts +90 -0
- package/dist/business/return.d.ts.map +1 -0
- package/dist/business/return.js +112 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +3 -1
- package/dist/dynamic-kanban.d.ts +62 -4
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +158 -27
- package/dist/dynamic-relation.d.ts +4 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +10 -1
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +14 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +28 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/reason-prompt.d.ts +59 -0
- package/dist/reason-prompt.d.ts.map +1 -0
- package/dist/reason-prompt.js +111 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-addon-settings.d.ts +17 -3
- package/dist/use-addon-settings.d.ts.map +1 -1
- package/dist/use-addon-settings.js +20 -8
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban.test.tsx +141 -0
- package/src/__tests__/prefill-from-record.test.ts +69 -0
- package/src/__tests__/reason-prompt.test.tsx +121 -0
- package/src/__tests__/use-addon-settings.test.ts +16 -1
- package/src/action-modal-dispatcher.tsx +145 -3
- package/src/activity-diff.tsx +10 -1
- package/src/business/business.test.ts +46 -0
- package/src/business/catalog.ts +8 -0
- package/src/business/index.ts +2 -0
- package/src/business/refund-destination.tsx +2 -2
- package/src/business/refund.ts +2 -1
- package/src/business/return-wizard.tsx +223 -0
- package/src/business/return.ts +181 -0
- package/src/dynamic-columns.tsx +5 -1
- package/src/dynamic-kanban.tsx +197 -19
- package/src/dynamic-relation.tsx +14 -1
- package/src/dynamic-row-actions.tsx +13 -1
- package/src/dynamic-table.tsx +22 -2
- package/src/index.ts +14 -0
- package/src/reason-prompt.tsx +247 -0
- package/src/types.ts +6 -0
- package/src/use-addon-settings.ts +26 -7
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -152,6 +152,7 @@ import { useFlipAnimation } from './use-flip-animation'
|
|
|
152
152
|
import { PortalDragOverlay } from './portal-drag-overlay'
|
|
153
153
|
import { objectLabel } from './dynamic-relation-helpers'
|
|
154
154
|
import { useMetadataCache } from './metadata-cache'
|
|
155
|
+
import { useOptionsResolver } from './use-options-resolver'
|
|
155
156
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
156
157
|
import { DynamicIcon } from './dynamic-icon'
|
|
157
158
|
import { isColumnVisibleInTable } from './column-visibility'
|
|
@@ -343,16 +344,28 @@ export function formatLaneCount(
|
|
|
343
344
|
return String(shown)
|
|
344
345
|
}
|
|
345
346
|
|
|
347
|
+
/** Reads a boolean-ish opt-in from a column's `styleConfig` (`display_config`). */
|
|
348
|
+
function cardFlag(col: ColumnDefinition, key: string): boolean {
|
|
349
|
+
const v = (col.styleConfig as Record<string, unknown> | undefined)?.[key]
|
|
350
|
+
return v === true || v === 'true' || v === 1
|
|
351
|
+
}
|
|
352
|
+
|
|
346
353
|
/**
|
|
347
|
-
* Picks the columns shown on a card: a `title` column
|
|
348
|
-
*
|
|
349
|
-
* the
|
|
350
|
-
*
|
|
354
|
+
* Picks the columns shown on a card: a `title` column and the secondary fields.
|
|
355
|
+
* Excludes the group_by column (it's the lane itself) and any column hidden
|
|
356
|
+
* from the table view (visibility modal/list, or `hidden`).
|
|
357
|
+
*
|
|
358
|
+
* A model can lay its card out explicitly through the columns' manifest
|
|
359
|
+
* `display_config`: `card_title: true` picks the title, `card: true` marks each
|
|
360
|
+
* field the card shows, in column order (`explicit` is then true and ALL the
|
|
361
|
+
* flagged fields are shown — the card is no longer capped at `maxFields`).
|
|
362
|
+
* Without any flag the legacy heuristic applies: the first searchable / text
|
|
363
|
+
* column is the title and the next `maxFields` visible columns are the fields.
|
|
351
364
|
*/
|
|
352
365
|
export function selectCardColumns(
|
|
353
366
|
metadata: TableMetadata,
|
|
354
367
|
maxFields = 3,
|
|
355
|
-
): { title: ColumnDefinition | null; fields: ColumnDefinition[] } {
|
|
368
|
+
): { title: ColumnDefinition | null; fields: ColumnDefinition[]; explicit: boolean } {
|
|
356
369
|
const groupBy = metadata.group_by
|
|
357
370
|
const visible = metadata.columns.filter(
|
|
358
371
|
(c) =>
|
|
@@ -361,15 +374,110 @@ export function selectCardColumns(
|
|
|
361
374
|
isColumnVisibleInTable(c) &&
|
|
362
375
|
c.key !== 'id',
|
|
363
376
|
)
|
|
377
|
+
const flagged = visible.filter((c) => cardFlag(c, 'card'))
|
|
378
|
+
const flaggedTitle = visible.find((c) => cardFlag(c, 'card_title'))
|
|
379
|
+
const explicit = flagged.length > 0 || !!flaggedTitle
|
|
364
380
|
const title =
|
|
381
|
+
flaggedTitle ??
|
|
365
382
|
visible.find((c) => c.searchable) ??
|
|
366
383
|
visible.find((c) => c.type === 'text' || c.cellStyle === 'truncate-text') ??
|
|
367
384
|
visible[0] ??
|
|
368
385
|
null
|
|
369
|
-
const fields =
|
|
370
|
-
.filter((c) => c.key !== title?.key)
|
|
371
|
-
.slice(0, maxFields)
|
|
372
|
-
return { title, fields }
|
|
386
|
+
const fields = explicit
|
|
387
|
+
? flagged.filter((c) => c.key !== title?.key)
|
|
388
|
+
: visible.filter((c) => c.key !== title?.key).slice(0, maxFields)
|
|
389
|
+
return { title, fields, explicit }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Whether a card's date field is past due — the SDK paints it as a warning.
|
|
394
|
+
* A column opts in with `display_config.overdue: true`; the optional
|
|
395
|
+
* `display_config.overdue_unless` (`{ <field>: [values…] }`) exempts cards whose
|
|
396
|
+
* row matches (a work order already delivered is never late). Pure — exported
|
|
397
|
+
* for unit tests.
|
|
398
|
+
*/
|
|
399
|
+
export function isCardOverdue(card: any, col: ColumnDefinition, now: number = Date.now()): boolean {
|
|
400
|
+
if (!cardFlag(col, 'overdue')) return false
|
|
401
|
+
const raw = card?.[col.key]
|
|
402
|
+
if (raw === null || raw === undefined || raw === '') return false
|
|
403
|
+
const at = typeof raw === 'number' ? raw : Date.parse(String(raw))
|
|
404
|
+
if (!Number.isFinite(at) || at >= now) return false
|
|
405
|
+
const unless = (col.styleConfig as Record<string, unknown> | undefined)?.overdue_unless
|
|
406
|
+
if (unless && typeof unless === 'object') {
|
|
407
|
+
for (const [field, values] of Object.entries(unless as Record<string, unknown>)) {
|
|
408
|
+
const list = Array.isArray(values) ? values : [values]
|
|
409
|
+
if (list.map(String).includes(String(card?.[field] ?? ''))) return false
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return true
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The metadata regrouped by another column (a nav entry's `group_by`). A model
|
|
417
|
+
* can serve several boards over the same records — by stage, by technician, by
|
|
418
|
+
* bay — but the host projects ONE `group_by` per model, so the entry's own wins.
|
|
419
|
+
* The stage machine (stages, transitions, smart lanes) belongs to the model's
|
|
420
|
+
* stage column (`stage_field`; older hosts that do not serve it: the served
|
|
421
|
+
* group_by): grouping by it keeps the machine, grouping by anything else drops
|
|
422
|
+
* it and the lanes come from the new column. Returns the SAME object when there
|
|
423
|
+
* is nothing to change (no override, the served group_by already, or a column the
|
|
424
|
+
* model does not have). Pure — exported for unit tests.
|
|
425
|
+
*/
|
|
426
|
+
export function withGroupBy(
|
|
427
|
+
metadata: TableMetadata | null,
|
|
428
|
+
groupBy: string | undefined,
|
|
429
|
+
): TableMetadata | null {
|
|
430
|
+
if (!metadata || !groupBy || groupBy === metadata.group_by) return metadata
|
|
431
|
+
if (!metadata.columns.some((c) => c.key === groupBy)) return metadata
|
|
432
|
+
const stageColumn = metadata.stage_field ?? (metadata.stages?.length ? metadata.group_by : undefined)
|
|
433
|
+
if (groupBy === stageColumn) return { ...metadata, group_by: groupBy }
|
|
434
|
+
return {
|
|
435
|
+
...metadata,
|
|
436
|
+
group_by: groupBy,
|
|
437
|
+
stages: undefined,
|
|
438
|
+
transitions: undefined,
|
|
439
|
+
smart_lanes: undefined,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Whether the board's lanes must come from the group_by column's REFERENCE
|
|
445
|
+
* (one lane per technician, per bay, per owner…): the metadata declares no
|
|
446
|
+
* stages, the column has no inline options, but it points at a record list.
|
|
447
|
+
* Pure — exported for unit tests.
|
|
448
|
+
*/
|
|
449
|
+
export function refLaneSource(
|
|
450
|
+
metadata: TableMetadata | null,
|
|
451
|
+
): { ref?: string; endpoint?: string } | null {
|
|
452
|
+
if (!metadata?.group_by) return null
|
|
453
|
+
if ((metadata.stages?.length ?? 0) > 0) return null
|
|
454
|
+
const col = metadata.columns.find((c) => c.key === metadata.group_by)
|
|
455
|
+
if (!col || (col.options?.length ?? 0) > 0) return null
|
|
456
|
+
if (col.searchEndpoint) return { endpoint: col.searchEndpoint }
|
|
457
|
+
if (col.ref) return { ref: col.ref }
|
|
458
|
+
return null
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The metadata with one lane per resolved option of a reference group_by column
|
|
463
|
+
* (labels ordered alphabetically). No transitions are declared, so a card can
|
|
464
|
+
* be dragged to any lane — i.e. re-assigned to another technician / bay.
|
|
465
|
+
* Returns the metadata untouched when there are no options. Pure.
|
|
466
|
+
*/
|
|
467
|
+
export function withRefLanes(
|
|
468
|
+
metadata: TableMetadata,
|
|
469
|
+
options: { value: string | number; label: string; color?: string | null }[],
|
|
470
|
+
): TableMetadata {
|
|
471
|
+
if (options.length === 0) return metadata
|
|
472
|
+
const stages: StageMeta[] = [...options]
|
|
473
|
+
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
|
|
474
|
+
.map((o, i) => ({
|
|
475
|
+
key: String(o.value),
|
|
476
|
+
label: String(o.label),
|
|
477
|
+
color: o.color ?? undefined,
|
|
478
|
+
order: i,
|
|
479
|
+
}))
|
|
480
|
+
return { ...metadata, stages }
|
|
373
481
|
}
|
|
374
482
|
|
|
375
483
|
/** The all-zeros UUID — a Go zero-value FK serialized as "set" when it isn't. */
|
|
@@ -724,6 +832,16 @@ export interface DynamicKanbanProps {
|
|
|
724
832
|
timeZone?: string
|
|
725
833
|
/** ISO 4217 currency for money card fields (org config). */
|
|
726
834
|
currency?: string
|
|
835
|
+
/**
|
|
836
|
+
* Column to group the board by, when the host's nav entry asks for one
|
|
837
|
+
* (`?group_by=`): a model can offer several boards over the same records —
|
|
838
|
+
* by stage, by technician, by bay. Absent or equal to the served
|
|
839
|
+
* `group_by` → the model's own stage machine. Otherwise the lanes come from
|
|
840
|
+
* that column (its options, or its referenced records) with no transitions,
|
|
841
|
+
* and the per-org stage customisations (custom lanes, overrides, lane order,
|
|
842
|
+
* automations) are off: they belong to the stage column.
|
|
843
|
+
*/
|
|
844
|
+
groupBy?: string
|
|
727
845
|
/**
|
|
728
846
|
* Static equality filters always applied to the board (never shown as a
|
|
729
847
|
* removable chip). Same contract as DynamicTable's `defaultFilters`.
|
|
@@ -791,6 +909,7 @@ function DynamicKanbanBoard({
|
|
|
791
909
|
timeZone: timeZoneProp,
|
|
792
910
|
currency: currencyProp,
|
|
793
911
|
defaultFilters,
|
|
912
|
+
groupBy: groupByProp,
|
|
794
913
|
}: DynamicKanbanProps) {
|
|
795
914
|
const { t, i18n } = useTranslation()
|
|
796
915
|
// The org's timezone/currency: an explicit prop wins, else the app-wide
|
|
@@ -811,16 +930,17 @@ function DynamicKanbanBoard({
|
|
|
811
930
|
|
|
812
931
|
// Stage automations (Bitrix-style per-lane rules). Degrades to no-op when
|
|
813
932
|
// the host has no `/stage-automations` endpoint — the ⚡ affordance hides.
|
|
814
|
-
const
|
|
933
|
+
const automationsHook = useStageAutomations(model)
|
|
815
934
|
|
|
816
935
|
// Custom stages (Bitrix-style user-defined columns). Degrades to no-op when
|
|
817
936
|
// the host has no `/custom-stages` endpoint — the "+ Agregar etapa" column
|
|
818
937
|
// and lane menus simply don't render.
|
|
819
|
-
const
|
|
938
|
+
const customStagesHook = useCustomStages(model)
|
|
820
939
|
// Per-org overrides for DECLARED lanes (rename/recolor/conditions). Degrades
|
|
821
940
|
// to no-op when the host has no `/stage-overrides` endpoint — the ⚙ gear then
|
|
822
941
|
// hides on declared lanes (custom lanes keep it via /custom-stages).
|
|
823
|
-
const
|
|
942
|
+
const stageOverridesHook = useStageOverrides(model)
|
|
943
|
+
const stageLayoutHook = useStageLayout(model)
|
|
824
944
|
// Dialog state: create/edit a stage, and the delete confirmation.
|
|
825
945
|
const [stageDialogOpen, setStageDialogOpen] = useState(false)
|
|
826
946
|
const [editingStage, setEditingStage] = useState<CustomStage | null>(null)
|
|
@@ -842,7 +962,47 @@ function DynamicKanbanBoard({
|
|
|
842
962
|
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
|
|
843
963
|
const cachedMeta = getMetadata(model)
|
|
844
964
|
|
|
845
|
-
const [
|
|
965
|
+
const [serverMetadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
|
|
966
|
+
// A reference group_by (technician, bay…) has no declared stages: its lanes
|
|
967
|
+
// are the referenced records, resolved through the same options endpoint the
|
|
968
|
+
// form selects use.
|
|
969
|
+
const routedMetadata = useMemo(
|
|
970
|
+
() => withGroupBy(serverMetadata, groupByProp),
|
|
971
|
+
[serverMetadata, groupByProp],
|
|
972
|
+
)
|
|
973
|
+
// Grouped by something other than the stage column: the stage machine is gone.
|
|
974
|
+
const regrouped =
|
|
975
|
+
(serverMetadata?.stages?.length ?? 0) > 0 && (routedMetadata?.stages?.length ?? 0) === 0
|
|
976
|
+
const laneSource = useMemo(() => refLaneSource(routedMetadata), [routedMetadata])
|
|
977
|
+
const { options: laneOptions } = useOptionsResolver({
|
|
978
|
+
modelKey: '',
|
|
979
|
+
fieldKey: 'id',
|
|
980
|
+
ref: laneSource?.ref,
|
|
981
|
+
endpoint: laneSource?.endpoint,
|
|
982
|
+
limit: 200,
|
|
983
|
+
enabled: !!laneSource,
|
|
984
|
+
})
|
|
985
|
+
const metadata = useMemo(
|
|
986
|
+
() => (routedMetadata && laneSource ? withRefLanes(routedMetadata, laneOptions) : routedMetadata),
|
|
987
|
+
[routedMetadata, laneSource, laneOptions],
|
|
988
|
+
)
|
|
989
|
+
// The stage customisations belong to the stage column: off on another grouping.
|
|
990
|
+
const automations = useMemo(
|
|
991
|
+
() => (regrouped ? { ...automationsHook, available: false } : automationsHook),
|
|
992
|
+
[regrouped, automationsHook],
|
|
993
|
+
)
|
|
994
|
+
const customStages = useMemo(
|
|
995
|
+
() => (regrouped ? { ...customStagesHook, available: false } : customStagesHook),
|
|
996
|
+
[regrouped, customStagesHook],
|
|
997
|
+
)
|
|
998
|
+
const stageOverrides = useMemo(
|
|
999
|
+
() => (regrouped ? { ...stageOverridesHook, available: false } : stageOverridesHook),
|
|
1000
|
+
[regrouped, stageOverridesHook],
|
|
1001
|
+
)
|
|
1002
|
+
const stageLayout = useMemo(
|
|
1003
|
+
() => (regrouped ? { ...stageLayoutHook, available: false } : stageLayoutHook),
|
|
1004
|
+
[regrouped, stageLayoutHook],
|
|
1005
|
+
)
|
|
846
1006
|
const [records, setRecords] = useState<any[]>([])
|
|
847
1007
|
const [loading, setLoading] = useState(!cachedMeta)
|
|
848
1008
|
const [loadingData, setLoadingData] = useState(true)
|
|
@@ -870,7 +1030,6 @@ function DynamicKanbanBoard({
|
|
|
870
1030
|
// the metadata (the backend also stamps `stages[]/smart_lanes[].order`, so the
|
|
871
1031
|
// board already paints ordered on load — this only backs the live drag + the
|
|
872
1032
|
// revert-on-failure). Null → follow the metadata order.
|
|
873
|
-
const stageLayout = useStageLayout(model)
|
|
874
1033
|
const [laneOrderOverride, setLaneOrderOverride] = useState<string[] | null>(null)
|
|
875
1034
|
const laneReorderEnabled = stageLayout.available
|
|
876
1035
|
|
|
@@ -1396,11 +1555,15 @@ function DynamicKanbanBoard({
|
|
|
1396
1555
|
|
|
1397
1556
|
// Card fields: a few more candidates than a card shows, so a card whose
|
|
1398
1557
|
// first fields are empty shows the next ones that have a value.
|
|
1399
|
-
const {
|
|
1558
|
+
const {
|
|
1559
|
+
title: titleCol,
|
|
1560
|
+
fields: fieldCols,
|
|
1561
|
+
explicit: explicitCard,
|
|
1562
|
+
} = useMemo(
|
|
1400
1563
|
() =>
|
|
1401
1564
|
metadata
|
|
1402
1565
|
? selectCardColumns(metadata, CARD_FIELD_CANDIDATES)
|
|
1403
|
-
: { title: null, fields: [] },
|
|
1566
|
+
: { title: null, fields: [], explicit: false },
|
|
1404
1567
|
[metadata],
|
|
1405
1568
|
)
|
|
1406
1569
|
|
|
@@ -1948,6 +2111,7 @@ function DynamicKanbanBoard({
|
|
|
1948
2111
|
card={card}
|
|
1949
2112
|
titleCol={titleCol}
|
|
1950
2113
|
fieldCols={fieldCols}
|
|
2114
|
+
showAllFields={explicitCard}
|
|
1951
2115
|
actions={rowActions}
|
|
1952
2116
|
locale={i18n.language}
|
|
1953
2117
|
timeZone={timeZone}
|
|
@@ -1979,6 +2143,7 @@ function DynamicKanbanBoard({
|
|
|
1979
2143
|
card={card}
|
|
1980
2144
|
titleCol={titleCol}
|
|
1981
2145
|
fieldCols={fieldCols}
|
|
2146
|
+
showAllFields={explicitCard}
|
|
1982
2147
|
actions={rowActions}
|
|
1983
2148
|
locale={i18n.language}
|
|
1984
2149
|
timeZone={timeZone}
|
|
@@ -2189,6 +2354,7 @@ function DynamicKanbanBoard({
|
|
|
2189
2354
|
card={activeCard}
|
|
2190
2355
|
titleCol={titleCol}
|
|
2191
2356
|
fieldCols={fieldCols}
|
|
2357
|
+
showAllFields={explicitCard}
|
|
2192
2358
|
locale={i18n.language}
|
|
2193
2359
|
timeZone={timeZone}
|
|
2194
2360
|
currency={currency}
|
|
@@ -3008,6 +3174,8 @@ interface KanbanCardProps {
|
|
|
3008
3174
|
titleCol: ColumnDefinition | null
|
|
3009
3175
|
/** Candidate fields; the card shows the first few that have a value. */
|
|
3010
3176
|
fieldCols: ColumnDefinition[]
|
|
3177
|
+
/** The model laid its card out explicitly: show every field with a value. */
|
|
3178
|
+
showAllFields?: boolean
|
|
3011
3179
|
actions: ActionDefinition[]
|
|
3012
3180
|
locale: string
|
|
3013
3181
|
timeZone?: string
|
|
@@ -3027,14 +3195,15 @@ function CardBody({
|
|
|
3027
3195
|
card,
|
|
3028
3196
|
titleCol,
|
|
3029
3197
|
fieldCols,
|
|
3198
|
+
showAllFields,
|
|
3030
3199
|
locale,
|
|
3031
3200
|
timeZone,
|
|
3032
3201
|
currency,
|
|
3033
3202
|
menu,
|
|
3034
|
-
}: Pick<KanbanCardProps, 'card' | 'titleCol' | 'fieldCols' | 'locale' | 'timeZone' | 'currency'> & {
|
|
3203
|
+
}: Pick<KanbanCardProps, 'card' | 'titleCol' | 'fieldCols' | 'showAllFields' | 'locale' | 'timeZone' | 'currency'> & {
|
|
3035
3204
|
menu?: React.ReactNode
|
|
3036
3205
|
}) {
|
|
3037
|
-
const fields = visibleCardFields(card, fieldCols, CARD_FIELDS_SHOWN)
|
|
3206
|
+
const fields = visibleCardFields(card, fieldCols, showAllFields ? fieldCols.length : CARD_FIELDS_SHOWN)
|
|
3038
3207
|
return (
|
|
3039
3208
|
<CardContent className="space-y-1.5 p-3">
|
|
3040
3209
|
<div className="flex items-start justify-between gap-2">
|
|
@@ -3056,7 +3225,12 @@ function CardBody({
|
|
|
3056
3225
|
{fields.map((col) => (
|
|
3057
3226
|
<div
|
|
3058
3227
|
key={col.key}
|
|
3059
|
-
className=
|
|
3228
|
+
className={`flex min-w-0 items-start gap-1.5 text-xs ${
|
|
3229
|
+
isCardOverdue(card, col)
|
|
3230
|
+
? 'font-medium text-destructive'
|
|
3231
|
+
: 'text-muted-foreground'
|
|
3232
|
+
}`}
|
|
3233
|
+
data-overdue={isCardOverdue(card, col) || undefined}
|
|
3060
3234
|
>
|
|
3061
3235
|
<span className="shrink-0 opacity-70">{col.label}:</span>
|
|
3062
3236
|
<span className="min-w-0 break-words">
|
|
@@ -3078,6 +3252,7 @@ function KanbanCard({
|
|
|
3078
3252
|
card,
|
|
3079
3253
|
titleCol,
|
|
3080
3254
|
fieldCols,
|
|
3255
|
+
showAllFields,
|
|
3081
3256
|
actions,
|
|
3082
3257
|
locale,
|
|
3083
3258
|
timeZone,
|
|
@@ -3145,6 +3320,7 @@ function KanbanCard({
|
|
|
3145
3320
|
card={card}
|
|
3146
3321
|
titleCol={titleCol}
|
|
3147
3322
|
fieldCols={fieldCols}
|
|
3323
|
+
showAllFields={showAllFields}
|
|
3148
3324
|
locale={locale}
|
|
3149
3325
|
timeZone={timeZone}
|
|
3150
3326
|
currency={currency}
|
|
@@ -3160,6 +3336,7 @@ function CardPreview({
|
|
|
3160
3336
|
card,
|
|
3161
3337
|
titleCol,
|
|
3162
3338
|
fieldCols,
|
|
3339
|
+
showAllFields,
|
|
3163
3340
|
locale,
|
|
3164
3341
|
timeZone,
|
|
3165
3342
|
currency,
|
|
@@ -3178,6 +3355,7 @@ function CardPreview({
|
|
|
3178
3355
|
card={card}
|
|
3179
3356
|
titleCol={titleCol}
|
|
3180
3357
|
fieldCols={fieldCols}
|
|
3358
|
+
showAllFields={showAllFields}
|
|
3181
3359
|
locale={locale}
|
|
3182
3360
|
timeZone={timeZone}
|
|
3183
3361
|
currency={currency}
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -83,6 +83,10 @@ export {
|
|
|
83
83
|
export interface DynamicRelationStrings {
|
|
84
84
|
title: string
|
|
85
85
|
emptyState: string
|
|
86
|
+
/** Sub-tabla sin permiso de lectura (HTTP 403). Default: "Sin permiso para ver estos registros." */
|
|
87
|
+
forbiddenState?: string
|
|
88
|
+
/** Falla al cargar la sub-tabla (no 403). Default: "No se pudieron cargar los registros relacionados." */
|
|
89
|
+
errorState?: string
|
|
86
90
|
addLabel: string
|
|
87
91
|
editLabel: string
|
|
88
92
|
removeLabel: string
|
|
@@ -252,6 +256,9 @@ function OneToManyRelation({
|
|
|
252
256
|
const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
|
|
253
257
|
const [rows, setRows] = useState<any[]>([])
|
|
254
258
|
const [loading, setLoading] = useState(true)
|
|
259
|
+
// HTTP status of the last failed fetch (null = ok). Without it a 403/500
|
|
260
|
+
// rendered the empty state and read as "no related rows" (PIT-045).
|
|
261
|
+
const [fetchErrorStatus, setFetchErrorStatus] = useState<number | null>(null)
|
|
255
262
|
// Paginación server-side + scroll infinito, igual que <DynamicTable>.
|
|
256
263
|
const [total, setTotal] = useState(0)
|
|
257
264
|
const [page, setPage] = useState(1)
|
|
@@ -341,6 +348,7 @@ function OneToManyRelation({
|
|
|
341
348
|
cacheMetadata(model, fresh)
|
|
342
349
|
}
|
|
343
350
|
const list = (dataRes as { data: ApiResponse<any[]> }).data
|
|
351
|
+
setFetchErrorStatus(null)
|
|
344
352
|
if (list.success) {
|
|
345
353
|
const fetched = list.data || []
|
|
346
354
|
setRows((prev) => (append ? dedupeById(prev, fetched) : fetched))
|
|
@@ -351,6 +359,7 @@ function OneToManyRelation({
|
|
|
351
359
|
}
|
|
352
360
|
} catch (err) {
|
|
353
361
|
console.error('DynamicRelation fetch error', err)
|
|
362
|
+
setFetchErrorStatus((err as { response?: { status?: number } })?.response?.status ?? 0)
|
|
354
363
|
} finally {
|
|
355
364
|
if (append) setLoadingMore(false)
|
|
356
365
|
else setLoading(false)
|
|
@@ -555,7 +564,11 @@ function OneToManyRelation({
|
|
|
555
564
|
</div>
|
|
556
565
|
) : rows.length === 0 ? (
|
|
557
566
|
<div className="text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30">
|
|
558
|
-
{
|
|
567
|
+
{fetchErrorStatus === 403
|
|
568
|
+
? (labels.forbiddenState ?? 'Sin permiso para ver estos registros.')
|
|
569
|
+
: fetchErrorStatus !== null
|
|
570
|
+
? (labels.errorState ?? 'No se pudieron cargar los registros relacionados.')
|
|
571
|
+
: labels.emptyState}
|
|
559
572
|
</div>
|
|
560
573
|
) : (
|
|
561
574
|
// Real metadata-driven table — same metacore-ui primitives and
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
AlertDialogTitle,
|
|
35
35
|
} from '@asteby/metacore-ui/primitives'
|
|
36
36
|
import { useApi } from './api-context'
|
|
37
|
+
import { useReasonPrompt } from './reason-prompt'
|
|
37
38
|
import { ActionModalDispatcher } from './action-modal-dispatcher'
|
|
38
39
|
import { DynamicRecordDialog } from './dialogs/dynamic-record'
|
|
39
40
|
import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon'
|
|
@@ -102,6 +103,9 @@ export function useDynamicRowActions({
|
|
|
102
103
|
|
|
103
104
|
const [rowToDelete, setRowToDelete] = useState<any | null>(null)
|
|
104
105
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
106
|
+
// Models that declare `reason_required.delete` answer a bare DELETE with 422
|
|
107
|
+
// errors.reason; the prompt asks once and retries with `?reason=`.
|
|
108
|
+
const reasonPrompt = useReasonPrompt()
|
|
105
109
|
|
|
106
110
|
const [actionModal, setActionModal] = useState<{
|
|
107
111
|
open: boolean
|
|
@@ -157,7 +161,14 @@ export function useDynamicRowActions({
|
|
|
157
161
|
setIsDeleting(true)
|
|
158
162
|
try {
|
|
159
163
|
const deleteEndpoint = writeBase ? `${writeBase}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
|
|
160
|
-
const res = await
|
|
164
|
+
const res = await reasonPrompt.run({
|
|
165
|
+
title: t('dynamic.delete_reason_title', { defaultValue: 'Eliminar registro' }),
|
|
166
|
+
description: t('dynamic.delete_reason_desc', { defaultValue: 'Este modelo pide el motivo de la eliminación.' }),
|
|
167
|
+
confirmLabel: t('dynamic.delete_confirm', { defaultValue: 'Eliminar' }),
|
|
168
|
+
request: (reason) => api.delete(deleteEndpoint, reason ? { params: { reason } } : undefined),
|
|
169
|
+
})
|
|
170
|
+
// El operador canceló el motivo: no se eliminó nada.
|
|
171
|
+
if (!res) return
|
|
161
172
|
// CRUD estándar: no usar res.data.message (el endpoint dinámico
|
|
162
173
|
// devuelve texto en inglés que se filtraría al toast). String localizado.
|
|
163
174
|
if (res.data.success) { toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' })); onRefresh() }
|
|
@@ -173,6 +184,7 @@ export function useDynamicRowActions({
|
|
|
173
184
|
|
|
174
185
|
const dialogs = (
|
|
175
186
|
<>
|
|
187
|
+
{reasonPrompt.dialog}
|
|
176
188
|
<AlertDialog open={!!rowToDelete} onOpenChange={(open: boolean) => !open && setRowToDelete(null)}>
|
|
177
189
|
<AlertDialogContent>
|
|
178
190
|
<AlertDialogHeader>
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -67,6 +67,7 @@ import { toast } from 'sonner'
|
|
|
67
67
|
import { Progress } from './dialogs/_primitives'
|
|
68
68
|
import { useMetadataCache } from './metadata-cache'
|
|
69
69
|
import { useApi, useCurrentBranch } from './api-context'
|
|
70
|
+
import { useReasonPrompt } from './reason-prompt'
|
|
70
71
|
import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
|
|
71
72
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
|
|
72
73
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
@@ -1123,6 +1124,7 @@ export function DynamicTable({
|
|
|
1123
1124
|
onRefresh: handleRefresh,
|
|
1124
1125
|
})
|
|
1125
1126
|
|
|
1127
|
+
const bulkReasonPrompt = useReasonPrompt()
|
|
1126
1128
|
const confirmBulkDelete = async () => {
|
|
1127
1129
|
const selectedRows = table.getFilteredSelectedRowModel().rows
|
|
1128
1130
|
if (selectedRows.length === 0) return
|
|
@@ -1130,13 +1132,29 @@ export function DynamicTable({
|
|
|
1130
1132
|
setBulkDeleteTotal(selectedRows.length)
|
|
1131
1133
|
setBulkDeleteProgress(0)
|
|
1132
1134
|
let successCount = 0, errorCount = 0
|
|
1135
|
+
// One reason for the whole batch: asked the first time a row is refused
|
|
1136
|
+
// for lacking it (model declares reason_required.delete), reused after.
|
|
1137
|
+
let bulkReason: string | undefined
|
|
1138
|
+
let bulkCancelled = false
|
|
1133
1139
|
for (let i = 0; i < selectedRows.length; i++) {
|
|
1134
1140
|
const row = selectedRows[i]
|
|
1141
|
+
if (bulkCancelled) { errorCount++; setBulkDeleteProgress(i + 1); continue }
|
|
1135
1142
|
try {
|
|
1136
1143
|
const writeBase = mutationEndpoint ?? endpoint
|
|
1137
1144
|
const deleteEndpoint = writeBase ? `${writeBase}/${row.original.id}` : `/data/${model}/${row.original.id}`
|
|
1138
|
-
const res = await
|
|
1139
|
-
|
|
1145
|
+
const res = await bulkReasonPrompt.run({
|
|
1146
|
+
title: t('dynamic.bulk_delete_reason_title', { count: selectedRows.length, defaultValue: 'Eliminar {{count}} registro(s)' }),
|
|
1147
|
+
description: t('dynamic.delete_reason_desc', { defaultValue: 'Este modelo pide el motivo de la eliminación.' }),
|
|
1148
|
+
confirmLabel: t('dynamic.delete_confirm', { defaultValue: 'Eliminar' }),
|
|
1149
|
+
reason: bulkReason,
|
|
1150
|
+
request: (reason) => {
|
|
1151
|
+
if (reason) bulkReason = reason
|
|
1152
|
+
return api.delete(deleteEndpoint, reason ? { params: { reason } } : undefined)
|
|
1153
|
+
},
|
|
1154
|
+
})
|
|
1155
|
+
if (!res) { bulkCancelled = true; errorCount++ }
|
|
1156
|
+
else if (res.data.success) successCount++
|
|
1157
|
+
else errorCount++
|
|
1140
1158
|
} catch (e) { console.error('Error al eliminar', e); errorCount++ }
|
|
1141
1159
|
setBulkDeleteProgress(i + 1)
|
|
1142
1160
|
}
|
|
@@ -1863,6 +1881,8 @@ export function DynamicTable({
|
|
|
1863
1881
|
</AlertDialogContent>
|
|
1864
1882
|
</AlertDialog>
|
|
1865
1883
|
|
|
1884
|
+
{bulkReasonPrompt.dialog}
|
|
1885
|
+
|
|
1866
1886
|
{exportEnabled && (
|
|
1867
1887
|
<ExportDialog open={exportOpen} onOpenChange={setExportOpen} model={model} metadata={metadata} currentFilters={buildFilterParams()} hasActiveFilters={hasActiveFilters} />
|
|
1868
1888
|
)}
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,10 @@ export {
|
|
|
60
60
|
applyStageOverrides,
|
|
61
61
|
laneDeltasFromOverrides,
|
|
62
62
|
selectCardColumns,
|
|
63
|
+
isCardOverdue,
|
|
64
|
+
refLaneSource,
|
|
65
|
+
withGroupBy,
|
|
66
|
+
withRefLanes,
|
|
63
67
|
visibleCardFields,
|
|
64
68
|
isEmptyCardValue,
|
|
65
69
|
nextLaneCoordinates,
|
|
@@ -237,6 +241,16 @@ export {
|
|
|
237
241
|
type ApprovalRequiredInfo,
|
|
238
242
|
type RequestApprovalOptions,
|
|
239
243
|
} from './approval-gate'
|
|
244
|
+
export {
|
|
245
|
+
ReasonPromptDialog,
|
|
246
|
+
useReasonPrompt,
|
|
247
|
+
reasonRequiredInfo,
|
|
248
|
+
DEFAULT_REASON_MIN,
|
|
249
|
+
type ReasonPromptDialogProps,
|
|
250
|
+
type ReasonPromptApi,
|
|
251
|
+
type ReasonRequiredInfo,
|
|
252
|
+
type RunWithReasonOptions,
|
|
253
|
+
} from './reason-prompt'
|
|
240
254
|
export {
|
|
241
255
|
useDynamicRowActions,
|
|
242
256
|
type UseDynamicRowActionsParams,
|