@softize/opus 12.7.0 → 12.8.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 +72 -0
- package/bin/lib/gen-dicts.mjs +11 -1
- package/bin/lib/gen-runner.mjs +8 -2
- package/bin/lib/materialize.mjs +5 -2
- package/docs/adr/0003-dictionary-presentation-is-declared.md +160 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +31 -10
- package/registry/skills/build-opus-ui/references/evaluations.md +25 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +85 -0
- package/registry/skills/implement-opus-change/SKILL.md +4 -3
- package/registry/skills/model-opus-dictionary/SKILL.md +76 -0
- package/registry/skills/model-opus-dictionary/agents/openai.yaml +4 -0
- package/registry/skills/model-opus-dictionary/references/evaluations.md +18 -0
- package/src/core/dictionary.ts +152 -0
- package/src/core/index.ts +18 -0
- package/src/core/types.ts +9 -1
- package/src/schema/drivers/zod.ts +46 -13
- package/src/ui/components/patterns/list.tsx +136 -51
- package/src/ui/components/primitives/badge.tsx +3 -0
- package/src/ui/components/primitives/detail.tsx +12 -1
- package/src/ui/components/primitives/dictionary-value.tsx +141 -0
- package/src/ui/components/primitives/empty-value.tsx +50 -0
- package/src/ui/components/primitives/pagination.tsx +86 -56
- package/src/ui/components/primitives/tabs.tsx +1 -1
- package/src/ui/docs/content/action-list-dialog.md +1 -1
- package/src/ui/docs/content/action-list.md +34 -9
- package/src/ui/docs/content/badge.md +6 -3
- package/src/ui/docs/content/detail.md +19 -2
- package/src/ui/docs/content/dictionary-value.md +120 -0
- package/src/ui/docs/content/empty-value.md +48 -0
- package/src/ui/docs/content/pagination.md +54 -17
- package/src/ui/docs/doc-client.tsx +21 -4
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/meta.ts +14 -2
- package/src/ui/react.tsx +6 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apresentação de dicionários — o vocabulário fechado e a tabela de decisão (ADR 0003).
|
|
3
|
+
*
|
|
4
|
+
* Mora no CORE porque é isomórfico: o driver de schema valida o que `t.dict` declara, o
|
|
5
|
+
* manifest projeta a metadata e a UI aplica os defaults lendo a meta que viaja no contrato.
|
|
6
|
+
* Nada aqui depende de React; `DictionaryValue` (ui/react) é só a materialização visual.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getLogicalType } from './logical-type.ts'
|
|
10
|
+
import type { LogicalTypeMeta } from './types.ts'
|
|
11
|
+
|
|
12
|
+
/** Papel de apresentação declarado no nível do dicionário. */
|
|
13
|
+
export const DICT_PRESENTATIONS = ['classification', 'status', 'stage', 'plain'] as const
|
|
14
|
+
export type DictPresentation = (typeof DICT_PRESENTATIONS)[number]
|
|
15
|
+
|
|
16
|
+
/** Tom explícito de uma entrada — honrado apenas por status e estágios. */
|
|
17
|
+
export const DICT_TONES = ['neutral', 'info', 'success', 'warning', 'danger'] as const
|
|
18
|
+
export type DictTone = (typeof DICT_TONES)[number]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Meta de cada chave de um dict. `label` obrigatório; resto livre.
|
|
22
|
+
*
|
|
23
|
+
* - `doc` é o entendimento de negócio da chave (o que aquele estado significa, quando se
|
|
24
|
+
* entra/sai) — a fonte rica de consulta rápida; flui pro manifest/Lens e NÃO vira tooltip.
|
|
25
|
+
* - `description` é o texto curto voltado à pessoa: aparece no tooltip do `DictionaryValue`
|
|
26
|
+
* e como apoio de opção. Não repetir o rótulo — descrição igual ao rótulo não renderiza.
|
|
27
|
+
* - `tone` é o tom tonal do badge de status/estágio; classificação ignora.
|
|
28
|
+
* - `icon` é o identificador estável do catálogo de ícones do Opus (`iconPickerIcons`);
|
|
29
|
+
* nome fora do catálogo não renderiza ícone.
|
|
30
|
+
* - `color` permanece metadata livre (lida pela Lens); o renderer não a interpreta.
|
|
31
|
+
*/
|
|
32
|
+
export interface DictEntryMeta {
|
|
33
|
+
label: string
|
|
34
|
+
doc?: string
|
|
35
|
+
description?: string
|
|
36
|
+
tone?: DictTone
|
|
37
|
+
icon?: string
|
|
38
|
+
color?: string
|
|
39
|
+
order?: number
|
|
40
|
+
[key: string]: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isDictPresentation(value: unknown): value is DictPresentation {
|
|
44
|
+
return typeof value === 'string' && (DICT_PRESENTATIONS as readonly string[]).includes(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isDictTone(value: unknown): value is DictTone {
|
|
48
|
+
return typeof value === 'string' && (DICT_TONES as readonly string[]).includes(value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Forma normalizada de um dicionário, independente de onde a meta foi lida. */
|
|
52
|
+
export interface DictionaryDescriptor {
|
|
53
|
+
keys: string[]
|
|
54
|
+
entries: Record<string, DictEntryMeta>
|
|
55
|
+
presentation?: DictPresentation
|
|
56
|
+
doc?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Lê a meta de `t.dict` de qualquer portador: a própria `LogicalTypeMeta`, um `DictType`
|
|
61
|
+
* (`{ meta }`) ou o schema com a meta anexada por `attachLogicalType` (o `.zod()` do dict).
|
|
62
|
+
* Retorna `undefined` quando a fonte não é um dicionário reconhecível.
|
|
63
|
+
*/
|
|
64
|
+
export function dictionaryDescriptor(source: unknown): DictionaryDescriptor | undefined {
|
|
65
|
+
if (source === null || typeof source !== 'object') return undefined
|
|
66
|
+
const attached = getLogicalType(source)
|
|
67
|
+
const meta = attached !== undefined
|
|
68
|
+
? attached
|
|
69
|
+
: 'logicalType' in source
|
|
70
|
+
? (source as LogicalTypeMeta)
|
|
71
|
+
: (source as { meta?: unknown }).meta
|
|
72
|
+
if (meta === null || typeof meta !== 'object') return undefined
|
|
73
|
+
const { logicalType, params } = meta as LogicalTypeMeta
|
|
74
|
+
if (logicalType !== 'dict' || params === undefined) return undefined
|
|
75
|
+
const keys = Array.isArray(params.keys) ? (params.keys as unknown[]).filter((k): k is string => typeof k === 'string') : []
|
|
76
|
+
const entries = params.entries !== null && typeof params.entries === 'object'
|
|
77
|
+
? (params.entries as Record<string, DictEntryMeta>)
|
|
78
|
+
: {}
|
|
79
|
+
return {
|
|
80
|
+
keys,
|
|
81
|
+
entries,
|
|
82
|
+
...(isDictPresentation(params.presentation) ? { presentation: params.presentation } : {}),
|
|
83
|
+
...(typeof params.doc === 'string' ? { doc: params.doc } : {}),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Resultado da tabela de decisão para UM valor — o que o renderer materializa. */
|
|
88
|
+
export interface DictionaryValuePresentation {
|
|
89
|
+
value: string
|
|
90
|
+
/** Rótulo sempre presente; valor desconhecido devolve o próprio código. */
|
|
91
|
+
label: string
|
|
92
|
+
/** False quando o valor não está no dicionário. */
|
|
93
|
+
known: boolean
|
|
94
|
+
presentation: DictPresentation
|
|
95
|
+
/** `null` = texto; `'outline'` = classificação; tom = badge tonal de status/estágio. */
|
|
96
|
+
badge: 'outline' | DictTone | null
|
|
97
|
+
/** Identificador do catálogo, só quando declarado na entrada. */
|
|
98
|
+
icon: string | null
|
|
99
|
+
/** Só quando `description` existe e acrescenta algo ao rótulo. */
|
|
100
|
+
description: string | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const normalizeText = (value: string): string =>
|
|
104
|
+
value.trim().replace(/\s+/g, ' ').replace(/[.。]+$/u, '').toLocaleLowerCase()
|
|
105
|
+
|
|
106
|
+
/** Descrição que só repete o rótulo não vira tooltip. */
|
|
107
|
+
export function dictionaryDescription(label: string, description: unknown): string | null {
|
|
108
|
+
if (typeof description !== 'string') return null
|
|
109
|
+
const text = description.trim()
|
|
110
|
+
if (text.length === 0) return null
|
|
111
|
+
return normalizeText(text) === normalizeText(label) ? null : text
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Aplica a tabela de decisão da ADR 0003:
|
|
116
|
+
*
|
|
117
|
+
* | papel | forma | variante |
|
|
118
|
+
* | classification | badge | outline (ignora `tone`) |
|
|
119
|
+
* | status / stage | badge | tonal: `tone` ?? neutral |
|
|
120
|
+
* | plain / ausente | texto | — |
|
|
121
|
+
*
|
|
122
|
+
* Ícone só quando declarado; tooltip só quando `description` acrescenta. Valor fora do
|
|
123
|
+
* dicionário vira texto puro com o código — nunca um badge colorido sobre algo desconhecido.
|
|
124
|
+
*/
|
|
125
|
+
export function presentDictionaryValue(
|
|
126
|
+
descriptor: DictionaryDescriptor,
|
|
127
|
+
value: string,
|
|
128
|
+
overrides: { presentation?: DictPresentation } = {},
|
|
129
|
+
): DictionaryValuePresentation {
|
|
130
|
+
const presentation = overrides.presentation ?? descriptor.presentation ?? 'plain'
|
|
131
|
+
const entry = Object.prototype.hasOwnProperty.call(descriptor.entries, value)
|
|
132
|
+
? descriptor.entries[value]
|
|
133
|
+
: undefined
|
|
134
|
+
if (entry === undefined || typeof entry.label !== 'string') {
|
|
135
|
+
return { value, label: value, known: false, presentation, badge: null, icon: null, description: null }
|
|
136
|
+
}
|
|
137
|
+
const badge: DictionaryValuePresentation['badge'] =
|
|
138
|
+
presentation === 'classification'
|
|
139
|
+
? 'outline'
|
|
140
|
+
: presentation === 'status' || presentation === 'stage'
|
|
141
|
+
? (isDictTone(entry.tone) ? entry.tone : 'neutral')
|
|
142
|
+
: null
|
|
143
|
+
return {
|
|
144
|
+
value,
|
|
145
|
+
label: entry.label,
|
|
146
|
+
known: true,
|
|
147
|
+
presentation,
|
|
148
|
+
badge,
|
|
149
|
+
icon: typeof entry.icon === 'string' && entry.icon.length > 0 ? entry.icon : null,
|
|
150
|
+
description: dictionaryDescription(entry.label, entry.description),
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -138,6 +138,24 @@ export { normalizeTraceContext } from './trace.ts'
|
|
|
138
138
|
// `@softize/opus/schema` re-exporta os dois pra manter a API de sempre.
|
|
139
139
|
export { attachLogicalType, getLogicalType } from './logical-type.ts'
|
|
140
140
|
|
|
141
|
+
// — Dicionários (apresentação declarada — ADR 0003) ————————————————————————————
|
|
142
|
+
export {
|
|
143
|
+
DICT_PRESENTATIONS,
|
|
144
|
+
DICT_TONES,
|
|
145
|
+
isDictPresentation,
|
|
146
|
+
isDictTone,
|
|
147
|
+
dictionaryDescriptor,
|
|
148
|
+
dictionaryDescription,
|
|
149
|
+
presentDictionaryValue,
|
|
150
|
+
} from './dictionary.ts'
|
|
151
|
+
export type {
|
|
152
|
+
DictPresentation,
|
|
153
|
+
DictTone,
|
|
154
|
+
DictEntryMeta,
|
|
155
|
+
DictionaryDescriptor,
|
|
156
|
+
DictionaryValuePresentation,
|
|
157
|
+
} from './dictionary.ts'
|
|
158
|
+
|
|
141
159
|
// — Action ————————————————————————————————————————————————————————————————————
|
|
142
160
|
export {
|
|
143
161
|
defineAction,
|
package/src/core/types.ts
CHANGED
|
@@ -626,8 +626,13 @@ export interface ListColumnSpec {
|
|
|
626
626
|
key: string
|
|
627
627
|
label: I18nRef
|
|
628
628
|
/** Render padrão: 'text' (default) · 'number' (alinha à direita) · 'date' (Intl) ·
|
|
629
|
-
* 'badge' (chip com o valor
|
|
629
|
+
* 'badge' (chip `outline` com o valor — legado; coluna de dicionário com `presentation`
|
|
630
|
+
* declarado usa `DictionaryValue` e dispensa este tipo). */
|
|
630
631
|
type?: 'text' | 'number' | 'date' | 'badge'
|
|
632
|
+
/** Referência do dicionário registrado em `TbdlibProvider dicts` que esta coluna mostra,
|
|
633
|
+
* quando o schema de saída não carrega a meta de `t.dict` (ex.: campo `z.string()`).
|
|
634
|
+
* Coluna cujo campo de saída É um `t.dict().zod()` resolve sozinha, sem esta chave. */
|
|
635
|
+
dictionary?: string
|
|
631
636
|
/** Header clicável (asc ↔ desc). Convenção: a UI escreve `sort: '<key>:<dir>'` no
|
|
632
637
|
* input; o handler implementa o orderBy. */
|
|
633
638
|
sortable?: boolean
|
|
@@ -637,6 +642,9 @@ export interface ListColumnSpec {
|
|
|
637
642
|
hidden?: boolean
|
|
638
643
|
/** Formato quando `type: 'date'` (Intl.DateTimeFormatOptions). */
|
|
639
644
|
dateFormat?: Intl.DateTimeFormatOptions
|
|
645
|
+
/** O que a ausência do valor significa nesta coluna (“Nunca enviado”, “Sem vencimento”).
|
|
646
|
+
* Sem isto, a célula mostra o travessão e “Não informado” à leitura assistiva. */
|
|
647
|
+
empty?: I18nRef
|
|
640
648
|
}
|
|
641
649
|
|
|
642
650
|
export interface SortSpec {
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
|
|
29
29
|
import { z, type ZodTypeAny, type ZodSchema } from 'zod'
|
|
30
30
|
import { attachLogicalType, type LogicalTypeMeta } from '../index.ts'
|
|
31
|
+
import {
|
|
32
|
+
DICT_PRESENTATIONS,
|
|
33
|
+
DICT_TONES,
|
|
34
|
+
isDictPresentation,
|
|
35
|
+
isDictTone,
|
|
36
|
+
type DictEntryMeta,
|
|
37
|
+
type DictPresentation,
|
|
38
|
+
} from '../../core/dictionary.ts'
|
|
31
39
|
import {
|
|
32
40
|
formatDatetime,
|
|
33
41
|
formatDate,
|
|
@@ -646,18 +654,11 @@ function isLogicalType(value: unknown): value is LogicalType<unknown> {
|
|
|
646
654
|
// =============================================================================
|
|
647
655
|
|
|
648
656
|
/**
|
|
649
|
-
* Meta de cada chave de um dict
|
|
650
|
-
*
|
|
651
|
-
*
|
|
657
|
+
* Meta de cada chave de um dict — o tipo mora no core (`@softize/opus/core`, ADR 0003) e é
|
|
658
|
+
* reexportado aqui pra manter a API: `label` obrigatório, `doc` (negócio, manifest/Lens),
|
|
659
|
+
* `description` (tooltip), `tone` (badge tonal de status/estágio), `icon` (catálogo).
|
|
652
660
|
*/
|
|
653
|
-
export
|
|
654
|
-
label: string
|
|
655
|
-
doc?: string
|
|
656
|
-
color?: string
|
|
657
|
-
icon?: string
|
|
658
|
-
order?: number
|
|
659
|
-
[key: string]: unknown
|
|
660
|
-
}
|
|
661
|
+
export type { DictEntryMeta }
|
|
661
662
|
|
|
662
663
|
/**
|
|
663
664
|
* Option pronta pra UI: combina key + meta da entrada.
|
|
@@ -686,11 +687,19 @@ export interface DictType<K extends string, M extends DictEntryMeta> {
|
|
|
686
687
|
has(key: string): key is K
|
|
687
688
|
/** Doc de negócio do dicionário inteiro (o que esse vocabulário representa). */
|
|
688
689
|
doc?: string
|
|
690
|
+
/** Papel de apresentação declarado (ADR 0003); ausente = texto (`plain`). */
|
|
691
|
+
presentation?: DictPresentation
|
|
689
692
|
}
|
|
690
693
|
|
|
691
|
-
/**
|
|
694
|
+
/**
|
|
695
|
+
* Opções do dict: `doc` = o entendimento do vocabulário como um todo; `presentation` = o
|
|
696
|
+
* papel de apresentação (`classification` · `status` · `stage` · `plain`) que o
|
|
697
|
+
* `DictionaryValue` e as colunas de `ActionList` honram. Sem `presentation`, o valor
|
|
698
|
+
* renderiza como texto — a apresentação é declarada, nunca inferida.
|
|
699
|
+
*/
|
|
692
700
|
export interface DictOpts {
|
|
693
701
|
doc?: string
|
|
702
|
+
presentation?: DictPresentation
|
|
694
703
|
}
|
|
695
704
|
|
|
696
705
|
const dict = <const M extends Record<string, DictEntryMeta>>(
|
|
@@ -702,10 +711,33 @@ const dict = <const M extends Record<string, DictEntryMeta>>(
|
|
|
702
711
|
if (keys.length === 0) {
|
|
703
712
|
throw new Error('t.dict precisa de pelo menos uma entrada')
|
|
704
713
|
}
|
|
714
|
+
if (opts?.presentation !== undefined && !isDictPresentation(opts.presentation)) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`t.dict: presentation "${String(opts.presentation)}" inválida; use ${DICT_PRESENTATIONS.join(' | ')}`,
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
const tonal = opts?.presentation === 'status' || opts?.presentation === 'stage'
|
|
720
|
+
for (const key of keys) {
|
|
721
|
+
const tone = (entries[key] as DictEntryMeta).tone
|
|
722
|
+
if (tone === undefined) continue
|
|
723
|
+
if (!isDictTone(tone)) {
|
|
724
|
+
throw new Error(`t.dict: tone "${String(tone)}" inválido em "${key}"; use ${DICT_TONES.join(' | ')}`)
|
|
725
|
+
}
|
|
726
|
+
if (!tonal) {
|
|
727
|
+
throw new Error(
|
|
728
|
+
`t.dict: tone em "${key}" exige presentation "status" ou "stage" (classificação usa outline; plain é texto)`,
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
705
732
|
const zodSchema = z.enum(keys as [K, ...K[]])
|
|
706
733
|
const meta: LogicalTypeMeta = {
|
|
707
734
|
logicalType: 'dict',
|
|
708
|
-
params: {
|
|
735
|
+
params: {
|
|
736
|
+
keys,
|
|
737
|
+
entries,
|
|
738
|
+
...(opts?.doc !== undefined ? { doc: opts.doc } : {}),
|
|
739
|
+
...(opts?.presentation !== undefined ? { presentation: opts.presentation } : {}),
|
|
740
|
+
},
|
|
709
741
|
}
|
|
710
742
|
attachLogicalType(zodSchema as unknown as object, meta)
|
|
711
743
|
|
|
@@ -713,6 +745,7 @@ const dict = <const M extends Record<string, DictEntryMeta>>(
|
|
|
713
745
|
zod: () => zodSchema,
|
|
714
746
|
meta,
|
|
715
747
|
...(opts?.doc !== undefined ? { doc: opts.doc } : {}),
|
|
748
|
+
...(opts?.presentation !== undefined ? { presentation: opts.presentation } : {}),
|
|
716
749
|
keys: () => [...keys],
|
|
717
750
|
labelFor: (key, _locale) => {
|
|
718
751
|
// _locale reservado pra i18n futuro; no v1 sempre retorna o label cru.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
23
|
-
import { ArrowDown, ArrowUp, ArrowUpDown, Calendar as CalendarIcon,
|
|
23
|
+
import { ArrowDown, ArrowUp, ArrowUpDown, Calendar as CalendarIcon, Eraser, RefreshCw, Settings2, SlidersHorizontal, Table2, X } from 'lucide-react'
|
|
24
24
|
import type {
|
|
25
25
|
ActionDef,
|
|
26
26
|
FilterSpec,
|
|
@@ -30,9 +30,22 @@ import type {
|
|
|
30
30
|
ListColumnSpec,
|
|
31
31
|
SortSpec,
|
|
32
32
|
} from '../../../core/index.ts'
|
|
33
|
+
import { dictionaryDescriptor, getLogicalType, type DictionaryDescriptor } from '../../../core/index.ts'
|
|
33
34
|
import { useDicts, useListAction, useLookupAction, type DictLike } from '../../drivers/react.tsx'
|
|
34
35
|
import { cn } from '../../lib/cn.ts'
|
|
36
|
+
import { objectSchemaShape } from '../../lib/object-schema.ts'
|
|
35
37
|
import { Badge } from '../primitives/badge.tsx'
|
|
38
|
+
import { DictionaryValue } from '../primitives/dictionary-value.tsx'
|
|
39
|
+
import { EmptyValue, isAbsentValue } from '../primitives/empty-value.tsx'
|
|
40
|
+
import {
|
|
41
|
+
Pagination,
|
|
42
|
+
PaginationContent,
|
|
43
|
+
PaginationEllipsis,
|
|
44
|
+
PaginationItem,
|
|
45
|
+
PaginationLink,
|
|
46
|
+
PaginationNext,
|
|
47
|
+
PaginationPrevious,
|
|
48
|
+
} from '../primitives/pagination.tsx'
|
|
36
49
|
import { Button } from '../primitives/button.tsx'
|
|
37
50
|
import { Calendar } from '../primitives/calendar.tsx'
|
|
38
51
|
import { Checkbox } from '../primitives/checkbox.tsx'
|
|
@@ -158,6 +171,9 @@ export function presetRange(preset: string, now = new Date()): { from: string; t
|
|
|
158
171
|
export interface ListActionLike {
|
|
159
172
|
name: string
|
|
160
173
|
kind: 'list'
|
|
174
|
+
/** Schema de saída (item). Coluna cujo campo carrega a meta de `t.dict` renderiza
|
|
175
|
+
* `DictionaryValue` sem configuração — ver `columnDictionary`. */
|
|
176
|
+
output?: unknown
|
|
161
177
|
columns?: ListColumnSpec[] | undefined
|
|
162
178
|
filters?: Record<string, FilterSpec> | undefined
|
|
163
179
|
sort?: { fields: string[]; default?: SortSpec[] } | undefined
|
|
@@ -270,6 +286,42 @@ function optionsFor(name: string, spec: FilterSpec, runtime?: Record<string, Sel
|
|
|
270
286
|
return []
|
|
271
287
|
}
|
|
272
288
|
|
|
289
|
+
/** Desce wrappers (optional/nullable/default) até o schema do campo. Estrutural, sem
|
|
290
|
+
* `instanceof`, pra funcionar com as representações v3 e v4 do Zod. */
|
|
291
|
+
function innerSchema(schema: object): object {
|
|
292
|
+
let current = schema
|
|
293
|
+
for (;;) {
|
|
294
|
+
const inner = (current as { _def?: { innerType?: unknown } })._def?.innerType
|
|
295
|
+
if (inner === null || typeof inner !== 'object') return current
|
|
296
|
+
current = inner
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Dicionário que uma coluna mostra (ADR 0003). Precedência: `dictionary` explícito no
|
|
301
|
+
* contrato (resolvido pelos dicts do provider) > meta de `t.dict` no campo do schema de
|
|
302
|
+
* saída (zero-config) > nada (a célula segue como texto). */
|
|
303
|
+
function columnDictionary(
|
|
304
|
+
column: ListColumnSpec,
|
|
305
|
+
outputShape: Record<string, unknown> | undefined,
|
|
306
|
+
dicts: Record<string, DictLike>,
|
|
307
|
+
): DictionaryDescriptor | undefined {
|
|
308
|
+
if (column.dictionary !== undefined) {
|
|
309
|
+
const dict = dicts[column.dictionary]
|
|
310
|
+
if (dict === undefined) return undefined
|
|
311
|
+
const fromMeta = dictionaryDescriptor(dict)
|
|
312
|
+
if (fromMeta !== undefined) return fromMeta
|
|
313
|
+
// Dict-like custom (só `options()`): rótulos sem papel de apresentação.
|
|
314
|
+
const options = dict.options()
|
|
315
|
+
return {
|
|
316
|
+
keys: options.map((o) => o.value),
|
|
317
|
+
entries: Object.fromEntries(options.map((o) => [o.value, { ...o, label: o.label }])),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const field = outputShape?.[column.key]
|
|
321
|
+
if (field === null || typeof field !== 'object') return undefined
|
|
322
|
+
return dictionaryDescriptor(getLogicalType(innerSchema(field)))
|
|
323
|
+
}
|
|
324
|
+
|
|
273
325
|
/** Opções de `options: { kind: 'dictionary', ref }` via dicts do provider. */
|
|
274
326
|
function dictOptionsFor(spec: FilterSpec, dicts: Record<string, DictLike>): SelectOption[] {
|
|
275
327
|
if (spec.options?.kind !== 'dictionary') return []
|
|
@@ -319,7 +371,9 @@ function FilterField({
|
|
|
319
371
|
options: SelectOption[]
|
|
320
372
|
/** Todos os valores de filtro vigentes — habilita `depends` e alimenta o lookup. */
|
|
321
373
|
allValues: Record<string, unknown>
|
|
322
|
-
/** Largura do contexto:
|
|
374
|
+
/** Largura do contexto: inline, cada tipo tem largura fixa e previsível (select `w-40`,
|
|
375
|
+
* lookup e múltiplo `w-52`, texto `w-40`); nunca cresce pelo conteúdo. O modal manda
|
|
376
|
+
* `w-full`, que vence pelo merge de classes. */
|
|
323
377
|
className?: string
|
|
324
378
|
}): React.ReactElement {
|
|
325
379
|
const label = text(spec.label, name)
|
|
@@ -344,7 +398,7 @@ function FilterField({
|
|
|
344
398
|
onChange={(v) => onChange(v)}
|
|
345
399
|
options={effOptions}
|
|
346
400
|
placeholder={placeholder}
|
|
347
|
-
className={cn('
|
|
401
|
+
className={cn('w-52', className)}
|
|
348
402
|
disabled={!enabled}
|
|
349
403
|
{...(lookup.onSearch !== undefined ? { onSearch: lookup.onSearch, loading: lookup.loading } : {})}
|
|
350
404
|
/>
|
|
@@ -362,7 +416,7 @@ function FilterField({
|
|
|
362
416
|
onChange={(v) => onChange(v)}
|
|
363
417
|
options={effOptions}
|
|
364
418
|
placeholder={placeholder}
|
|
365
|
-
className={cn('
|
|
419
|
+
className={cn('w-52', className)}
|
|
366
420
|
disabled={!enabled}
|
|
367
421
|
{...(lookup.onSearch !== undefined ? { onSearch: lookup.onSearch, loading: lookup.loading } : {})}
|
|
368
422
|
/>
|
|
@@ -380,7 +434,7 @@ function FilterField({
|
|
|
380
434
|
onChange={(v) => onChange(v === ALL ? '' : v)}
|
|
381
435
|
options={[{ value: ALL, label: text(spec.placeholder, 'Todos') }, ...base]}
|
|
382
436
|
disabled={!enabled}
|
|
383
|
-
className={cn('
|
|
437
|
+
className={cn('w-40', className)}
|
|
384
438
|
/>
|
|
385
439
|
)
|
|
386
440
|
}
|
|
@@ -497,7 +551,15 @@ function Labeled({
|
|
|
497
551
|
}): React.ReactElement {
|
|
498
552
|
return (
|
|
499
553
|
<div data-slot={slot} className="flex shrink-0 flex-col gap-1">
|
|
500
|
-
|
|
554
|
+
{/* w-0 + min-w-full: a label acompanha a largura do controle e nunca a dita —
|
|
555
|
+
um rótulo longo trunca em vez de alargar o filtro. */}
|
|
556
|
+
<span
|
|
557
|
+
data-slot="action-filter-label"
|
|
558
|
+
title={label}
|
|
559
|
+
className="block w-0 min-w-full truncate px-0.5 text-xs font-medium text-muted-foreground"
|
|
560
|
+
>
|
|
561
|
+
{label}
|
|
562
|
+
</span>
|
|
501
563
|
{children}
|
|
502
564
|
</div>
|
|
503
565
|
)
|
|
@@ -993,11 +1055,16 @@ export function ActionList<TInput, TItem>({
|
|
|
993
1055
|
// — Colunas: prop explícita > derivadas do contrato (+ células custom). O column
|
|
994
1056
|
// picker (state.columns) escolhe o subconjunto visível; null = o default (!hidden).
|
|
995
1057
|
const visibleKeys = state.columns
|
|
1058
|
+
const dicts = useDicts()
|
|
996
1059
|
const derived: Array<ActionListColumn<TItem> & { spec?: ListColumnSpec }> = useMemo(() => {
|
|
997
1060
|
if (columns !== undefined) return columns
|
|
1061
|
+
const outputShape = objectSchemaShape(action.output)
|
|
998
1062
|
return (action.columns ?? [])
|
|
999
1063
|
.filter((c) => (visibleKeys !== null ? visibleKeys.includes(c.key) : c.hidden !== true))
|
|
1000
|
-
.map((c) =>
|
|
1064
|
+
.map((c) => {
|
|
1065
|
+
// Resolvido uma vez por coluna: não depende do item.
|
|
1066
|
+
const dictionary = cells?.[c.key] === undefined ? columnDictionary(c, outputShape, dicts) : undefined
|
|
1067
|
+
return {
|
|
1001
1068
|
key: c.key,
|
|
1002
1069
|
header: text(c.label, c.key),
|
|
1003
1070
|
sortable: c.sortable === true,
|
|
@@ -1007,7 +1074,24 @@ export function ActionList<TInput, TItem>({
|
|
|
1007
1074
|
cells?.[c.key] ??
|
|
1008
1075
|
((item: TItem): ReactNode => {
|
|
1009
1076
|
const v = (item as Record<string, unknown>)[c.key]
|
|
1010
|
-
|
|
1077
|
+
// Ausência (null · undefined · '' · só espaços) tem representação padrão; 0 e
|
|
1078
|
+
// false são valores. O contrato troca o significado por `empty`.
|
|
1079
|
+
if (isAbsentValue(v)) {
|
|
1080
|
+
const label = text(c.empty, '')
|
|
1081
|
+
return <EmptyValue compact {...(label !== '' ? { label } : {})} />
|
|
1082
|
+
}
|
|
1083
|
+
if (dictionary !== undefined && typeof v === 'string') {
|
|
1084
|
+
// Sem `presentation` declarado o valor é texto (rótulo); `type: 'badge'`
|
|
1085
|
+
// legado mantém o chip outline, agora com o rótulo em vez do código.
|
|
1086
|
+
const legacyBadge = c.type === 'badge' && dictionary.presentation === undefined
|
|
1087
|
+
return (
|
|
1088
|
+
<DictionaryValue
|
|
1089
|
+
dict={dictionary}
|
|
1090
|
+
value={v}
|
|
1091
|
+
{...(legacyBadge ? { variant: 'outline' as const } : {})}
|
|
1092
|
+
/>
|
|
1093
|
+
)
|
|
1094
|
+
}
|
|
1011
1095
|
if (c.type === 'date') {
|
|
1012
1096
|
// Date-only ('YYYY-MM-DD') parseia LOCAL — new Date() iria pra meia-noite
|
|
1013
1097
|
// UTC e deslocaria um dia no fuso; ISO completo segue no caminho normal.
|
|
@@ -1020,8 +1104,9 @@ export function ActionList<TInput, TItem>({
|
|
|
1020
1104
|
if (c.type === 'badge') return <Badge variant="outline">{String(v)}</Badge>
|
|
1021
1105
|
return typeof v === 'object' ? null : String(v)
|
|
1022
1106
|
}),
|
|
1023
|
-
|
|
1024
|
-
|
|
1107
|
+
}
|
|
1108
|
+
})
|
|
1109
|
+
}, [columns, action.columns, action.output, cells, visibleKeys, dicts])
|
|
1025
1110
|
|
|
1026
1111
|
// — Column picker: só faz sentido na tabela derivada do contrato —
|
|
1027
1112
|
const pickerColumns = columns === undefined ? (action.columns ?? []) : []
|
|
@@ -1330,47 +1415,47 @@ export function ActionList<TInput, TItem>({
|
|
|
1330
1415
|
Página {state.page} de {pages}
|
|
1331
1416
|
</span>
|
|
1332
1417
|
{pages > 1 && (
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
</
|
|
1373
|
-
</
|
|
1418
|
+
// A primitiva pública na escala densa do rodapé: setas quadradas `size-7`,
|
|
1419
|
+
// números `h-7 min-w-7` que crescem com os dígitos (5726 e 5727 não se colam).
|
|
1420
|
+
<Pagination className="mx-0 w-auto">
|
|
1421
|
+
<PaginationContent className="gap-0.5">
|
|
1422
|
+
<PaginationItem>
|
|
1423
|
+
<PaginationPrevious
|
|
1424
|
+
iconOnly
|
|
1425
|
+
className="size-7"
|
|
1426
|
+
iconClassName="size-3.5"
|
|
1427
|
+
disabled={state.page <= 1}
|
|
1428
|
+
onClick={() => goTo(state.page - 1)}
|
|
1429
|
+
/>
|
|
1430
|
+
</PaginationItem>
|
|
1431
|
+
{pageWindow(state.page, pages).map((n, i) =>
|
|
1432
|
+
n === '…' ? (
|
|
1433
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
1434
|
+
<PaginationItem key={`e${i}`}>
|
|
1435
|
+
<PaginationEllipsis className="size-7" />
|
|
1436
|
+
</PaginationItem>
|
|
1437
|
+
) : (
|
|
1438
|
+
<PaginationItem key={n}>
|
|
1439
|
+
<PaginationLink
|
|
1440
|
+
page={n}
|
|
1441
|
+
isActive={n === state.page}
|
|
1442
|
+
className="h-7 min-w-7 text-xs"
|
|
1443
|
+
onClick={() => goTo(n)}
|
|
1444
|
+
/>
|
|
1445
|
+
</PaginationItem>
|
|
1446
|
+
),
|
|
1447
|
+
)}
|
|
1448
|
+
<PaginationItem>
|
|
1449
|
+
<PaginationNext
|
|
1450
|
+
iconOnly
|
|
1451
|
+
className="size-7"
|
|
1452
|
+
iconClassName="size-3.5"
|
|
1453
|
+
disabled={state.page >= pages}
|
|
1454
|
+
onClick={() => goTo(state.page + 1)}
|
|
1455
|
+
/>
|
|
1456
|
+
</PaginationItem>
|
|
1457
|
+
</PaginationContent>
|
|
1458
|
+
</Pagination>
|
|
1374
1459
|
)}
|
|
1375
1460
|
{/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
|
|
1376
1461
|
{pages <= 1 && <span />}
|
|
@@ -18,6 +18,9 @@ export const badgeVariants = cva(
|
|
|
18
18
|
success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
|
|
19
19
|
warning: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400',
|
|
20
20
|
info: 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400',
|
|
21
|
+
// `danger` é o tom tonal da família (status/estágio em `DictionaryValue`);
|
|
22
|
+
// `destructive` continua sólido, reservado a alerta/ação.
|
|
23
|
+
danger: 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400',
|
|
21
24
|
},
|
|
22
25
|
},
|
|
23
26
|
defaultVariants: { variant: 'default' },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
|
|
3
3
|
import { cn } from '../../lib/cn.ts'
|
|
4
|
+
import { EmptyValue, isAbsentValue } from './empty-value.tsx'
|
|
4
5
|
|
|
5
6
|
export type DetailGroupVariant = 'plain' | 'framed'
|
|
6
7
|
export type DetailGroupOrientation = 'vertical' | 'horizontal'
|
|
@@ -60,9 +61,13 @@ export interface DetailFieldProps extends Omit<
|
|
|
60
61
|
'children'
|
|
61
62
|
> {
|
|
62
63
|
label: React.ReactNode
|
|
64
|
+
/** `null`, `undefined`, string vazia ou só com espaços renderizam a ausência
|
|
65
|
+
* (“Não informado” ou `empty`); `0` e `false` seguem como valores. */
|
|
63
66
|
value: React.ReactNode
|
|
64
67
|
/** Ícone decorativo que identifica o campo antes do par chave/valor. */
|
|
65
68
|
icon?: React.ReactNode
|
|
69
|
+
/** O que a ausência significa neste campo: um rótulo (“Nunca enviado”) ou um nó próprio. */
|
|
70
|
+
empty?: React.ReactNode
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
function DetailField({
|
|
@@ -70,8 +75,14 @@ function DetailField({
|
|
|
70
75
|
label,
|
|
71
76
|
value,
|
|
72
77
|
icon,
|
|
78
|
+
empty,
|
|
73
79
|
...props
|
|
74
80
|
}: DetailFieldProps): React.ReactElement {
|
|
81
|
+
const content = !isAbsentValue(value)
|
|
82
|
+
? value
|
|
83
|
+
: empty === undefined || typeof empty === 'string'
|
|
84
|
+
? <EmptyValue {...(empty !== undefined ? { label: empty } : {})} />
|
|
85
|
+
: empty
|
|
75
86
|
return (
|
|
76
87
|
<div
|
|
77
88
|
data-slot="detail-field"
|
|
@@ -104,7 +115,7 @@ function DetailField({
|
|
|
104
115
|
data-slot="detail-field-value"
|
|
105
116
|
className="min-w-0 text-sm leading-snug font-normal"
|
|
106
117
|
>
|
|
107
|
-
{
|
|
118
|
+
{content}
|
|
108
119
|
</dd>
|
|
109
120
|
</div>
|
|
110
121
|
)
|