@softize/opus 12.5.5 → 12.6.1

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.
@@ -58,10 +58,10 @@ import { Separator } from '../primitives/separator.tsx'
58
58
  import { Label } from '../primitives/label.tsx'
59
59
  import { Search } from 'lucide-react'
60
60
  import { Select, type SelectOption } from '../primitives/select.tsx'
61
- import { Skeleton } from '../primitives/skeleton.tsx'
62
61
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table.tsx'
63
62
  import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
64
63
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
64
+ import { DataState } from './data-state.tsx'
65
65
 
66
66
  // =============================================================================
67
67
  // Helpers
@@ -165,6 +165,26 @@ export interface ListActionLike {
165
165
  periods?: PeriodSpec[] | undefined
166
166
  }
167
167
 
168
+ /** Estado compartilhado pelos filtros declarativos, independente da listagem. */
169
+ export interface ActionFilterState {
170
+ q: string
171
+ filters: Record<string, unknown>
172
+ period: string | null
173
+ from: string | null
174
+ to: string | null
175
+ }
176
+
177
+ export interface ActionFilterBarProps {
178
+ action: Pick<ListActionLike, 'filters' | 'text' | 'periods'>
179
+ state: ActionFilterState
180
+ onStateChange: (next: ActionFilterState) => void
181
+ filterOptions?: Record<string, SelectOption[]>
182
+ onRefresh?: () => Promise<void> | void
183
+ refreshing?: boolean
184
+ /** Ações de apresentação do consumidor, renderizadas depois do refresh. */
185
+ actions?: ReactNode
186
+ }
187
+
168
188
  /** Uma view alternativa da MESMA listagem (board, galeria, lista, calendário…). A
169
189
  * apresentação é de quem chama; o pattern dá o segment, o estado e os dados. */
170
190
  export interface ActionListView<TItem> {
@@ -684,6 +704,193 @@ function AdvancedFiltersDialog({
684
704
  )
685
705
  }
686
706
 
707
+ /** Barra declarativa compartilhada por ActionList, relatórios e outras superfícies. */
708
+ export function ActionFilterBar({
709
+ action,
710
+ state,
711
+ onStateChange,
712
+ filterOptions,
713
+ onRefresh,
714
+ refreshing = false,
715
+ actions,
716
+ }: ActionFilterBarProps): React.ReactElement {
717
+ const dicts = useDicts()
718
+ const filterSpecs = Object.entries(action.filters ?? {})
719
+ const inlineSpecs = filterSpecs.filter(([, spec]) => spec.advanced !== true)
720
+ const hasSearch = (action.text?.fields.length ?? 0) > 0
721
+ const periods = action.periods ?? []
722
+ const defaultPeriod = periods.find((period) => period.default === true)?.value ?? periods[0]?.value ?? null
723
+ const activePeriod = state.period ?? defaultPeriod
724
+ const [advancedOpen, setAdvancedOpen] = useState(false)
725
+ const toolbarRowRef = useRef<HTMLDivElement | null>(null)
726
+ const [inlineFit, setInlineFit] = useState(Number.POSITIVE_INFINITY)
727
+
728
+ const cascadeClear = (changed: string, next: Record<string, unknown>): Record<string, unknown> => {
729
+ for (const [name, spec] of filterSpecs) {
730
+ if (spec.depends?.includes(changed) === true && !isEmptyValue(next[name])) {
731
+ delete next[name]
732
+ cascadeClear(name, next)
733
+ }
734
+ }
735
+ return next
736
+ }
737
+
738
+ useLayoutEffect(() => {
739
+ const row = toolbarRowRef.current
740
+ if (row === null) return
741
+ const overflowing = (): boolean =>
742
+ row.scrollWidth > row.clientWidth + 1 ||
743
+ Array.from(row.children).some((child) => child.scrollWidth > child.clientWidth + 1)
744
+ const compute = (): void => {
745
+ row.classList.remove('flex-wrap')
746
+ const nodes = Array.from(row.querySelectorAll<HTMLElement>('[data-slot="action-filter-inline"]'))
747
+ for (const node of nodes) node.style.removeProperty('display')
748
+ let count = nodes.length
749
+ while (count > 0 && overflowing()) {
750
+ count -= 1
751
+ nodes[count]?.style.setProperty('display', 'none')
752
+ }
753
+ if (count === 0 && overflowing()) row.classList.add('flex-wrap')
754
+ setInlineFit(count >= nodes.length ? Number.POSITIVE_INFINITY : count)
755
+ }
756
+ compute()
757
+ const observer = new ResizeObserver(compute)
758
+ observer.observe(row)
759
+ return () => observer.disconnect()
760
+ }, [inlineSpecs.length, state.filters, inlineFit])
761
+
762
+ const overflowSpecs = Number.isFinite(inlineFit) ? inlineSpecs.slice(inlineFit) : []
763
+ const modalSpecs = filterSpecs.filter(
764
+ ([name, spec]) => spec.advanced === true || overflowSpecs.some(([overflowName]) => overflowName === name),
765
+ )
766
+ const modalActive = modalSpecs.filter(([name]) => !isEmptyValue(state.filters[name])).length
767
+ const setFilter = (name: string, value: unknown): void => {
768
+ onStateChange({
769
+ ...state,
770
+ filters: cascadeClear(name, { ...state.filters, [name]: value }),
771
+ })
772
+ }
773
+ const chips = modalSpecs
774
+ .filter(([name]) => !isEmptyValue(state.filters[name]))
775
+ .map(([name, spec]) => {
776
+ const raw = state.filters[name]
777
+ const fromProps = optionsFor(name, spec, filterOptions)
778
+ const opts = fromProps.length > 0 ? fromProps : dictOptionsFor(spec, dicts)
779
+ const display = (value: unknown): string => opts.find((option) => option.value === value)?.label ?? String(value)
780
+ return {
781
+ name,
782
+ label: text(spec.label, name),
783
+ value: Array.isArray(raw) ? raw.map(display).join(', ') : display(raw),
784
+ }
785
+ })
786
+ const searchFirst = hasSearch && periods.length === 0 && filterSpecs.length === 0
787
+ const searchBox = hasSearch ? (
788
+ <ClearableInput
789
+ className="w-64 min-w-36"
790
+ leading={<Search />}
791
+ applied={state.q}
792
+ placeholder={text(action.text?.placeholder, 'Buscar…')}
793
+ onApply={(q) => q !== state.q && onStateChange({ ...state, q })}
794
+ />
795
+ ) : null
796
+
797
+ return (
798
+ <TooltipProvider>
799
+ <div data-slot="action-filter-bar" className="space-y-2">
800
+ <div ref={toolbarRowRef} className="flex items-end gap-3">
801
+ {searchFirst && searchBox}
802
+ {periods.length > 0 && (
803
+ <Labeled label="Período">
804
+ <PeriodControl
805
+ periods={periods}
806
+ period={activePeriod}
807
+ from={state.from}
808
+ to={state.to}
809
+ onChange={(next) => onStateChange({
810
+ ...state,
811
+ ...next,
812
+ period: next.period === defaultPeriod ? null : next.period,
813
+ })}
814
+ />
815
+ </Labeled>
816
+ )}
817
+ {inlineSpecs.map(([name, spec]) => (
818
+ <Labeled key={name} label={text(spec.label, name)} slot="action-filter-inline">
819
+ <FilterField
820
+ name={name}
821
+ spec={spec}
822
+ value={state.filters[name]}
823
+ allValues={state.filters}
824
+ onChange={(value) => setFilter(name, value)}
825
+ options={optionsFor(name, spec, filterOptions)}
826
+ />
827
+ </Labeled>
828
+ ))}
829
+ {modalSpecs.length > 0 && (
830
+ <Button variant="outline" className="shrink-0" onClick={() => setAdvancedOpen(true)}>
831
+ <SlidersHorizontal className="h-3.5 w-3.5" />
832
+ Filtros
833
+ {modalActive > 0 && <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">{modalActive}</Badge>}
834
+ </Button>
835
+ )}
836
+ <div className="ml-auto flex min-w-0 items-center gap-2">
837
+ {!searchFirst && searchBox}
838
+ {onRefresh !== undefined && (
839
+ <Tooltip>
840
+ <TooltipTrigger asChild>
841
+ <Button
842
+ variant="outline"
843
+ className="shrink-0"
844
+ aria-label="Recarregar"
845
+ onClick={() => void onRefresh()}
846
+ disabled={refreshing}
847
+ >
848
+ <RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
849
+ </Button>
850
+ </TooltipTrigger>
851
+ <TooltipContent>Recarregar</TooltipContent>
852
+ </Tooltip>
853
+ )}
854
+ {actions}
855
+ </div>
856
+ </div>
857
+ {chips.length > 0 && (
858
+ <div data-slot="action-filter-chips" className="flex flex-wrap items-center gap-1.5">
859
+ {chips.map((chip) => (
860
+ <Badge key={chip.name} variant="secondary" className="gap-1 pr-1">
861
+ <span className="text-muted-foreground">{chip.label}:</span> {chip.value}
862
+ <button
863
+ type="button"
864
+ aria-label={`Remover filtro ${chip.label}`}
865
+ onClick={() => {
866
+ const next = { ...state.filters }
867
+ delete next[chip.name]
868
+ onStateChange({ ...state, filters: next })
869
+ }}
870
+ className="rounded-sm text-muted-foreground hover:text-foreground"
871
+ >
872
+ <X className="size-3" />
873
+ </button>
874
+ </Badge>
875
+ ))}
876
+ </div>
877
+ )}
878
+ {modalSpecs.length > 0 && (
879
+ <AdvancedFiltersDialog
880
+ open={advancedOpen}
881
+ onOpenChange={setAdvancedOpen}
882
+ specs={modalSpecs}
883
+ values={state.filters}
884
+ cascade={cascadeClear}
885
+ onApply={(filters) => onStateChange({ ...state, filters })}
886
+ filterOptions={filterOptions}
887
+ />
888
+ )}
889
+ </div>
890
+ </TooltipProvider>
891
+ )
892
+ }
893
+
687
894
  // =============================================================================
688
895
  // ActionList
689
896
  // =============================================================================
@@ -719,8 +926,6 @@ export function ActionList<TInput, TItem>({
719
926
  // — Estado da toolbar (interno ou controlado) —
720
927
  const defaultSort = action.sort?.default?.[0] ?? null
721
928
  const [internal, setInternal] = useState<ActionListState>({ ...emptyListState(), sort: defaultSort })
722
- // Dicts do provider — labels de filtro dictionary nos CHIPS (o FilterField resolve os seus).
723
- const dicts = useDicts()
724
929
  // Normaliza estado externo montado à mão (chaves novas ausentes = null/default).
725
930
  const raw = controlledState ?? internal
726
931
  const state: ActionListState = {
@@ -741,64 +946,7 @@ export function ActionList<TInput, TItem>({
741
946
  const setStateResetPage = (next: ActionListState): void => setState({ ...next, page: 1 })
742
947
 
743
948
  const filterSpecs = Object.entries(action.filters ?? {})
744
- // Mudou um filtro → limpa (em cascata) todo filtro que declara `depends` nele:
745
- // o recorte dependente perde o sentido quando o pai muda.
746
- const cascadeClear = (changed: string, next: Record<string, unknown>): Record<string, unknown> => {
747
- for (const [n, sp] of filterSpecs) {
748
- if (sp.depends?.includes(changed) === true && !isEmptyValue(next[n])) {
749
- delete next[n]
750
- cascadeClear(n, next)
751
- }
752
- }
753
- return next
754
- }
755
- const inlineSpecs = filterSpecs.filter(([, s]) => s.advanced !== true)
756
949
  const hasSearch = (action.text?.fields.length ?? 0) > 0
757
- const [advancedOpen, setAdvancedOpen] = useState(false)
758
-
759
- // — Overflow responsivo: filtro inline que NÃO CABE na linha vai pro modal (com os
760
- // advanced). Medição real: esconde do fim pro começo até a linha parar de
761
- // transbordar (useLayoutEffect = antes do paint, sem piscar) e re-mede no resize.
762
- const toolbarRowRef = useRef<HTMLDivElement | null>(null)
763
- const [inlineFit, setInlineFit] = useState(Number.POSITIVE_INFINITY)
764
- useLayoutEffect(() => {
765
- const row = toolbarRowRef.current
766
- if (row === null) return
767
- // Transborda de dois jeitos: a linha estoura (scrollWidth) OU um filho flexível
768
- // (o grupo da direita, min-w-0) encolhe abaixo do conteúdo e vaza por cima dos
769
- // vizinhos — esse segundo caso não mexe no scrollWidth da linha.
770
- const overflowing = (): boolean =>
771
- row.scrollWidth > row.clientWidth + 1 ||
772
- Array.from(row.children).some((c) => c.scrollWidth > c.clientWidth + 1)
773
- const compute = (): void => {
774
- // Medição sempre em linha única; o wrap é o último recurso (re-avaliado abaixo).
775
- row.classList.remove('flex-wrap')
776
- const nodes = Array.from(row.querySelectorAll<HTMLElement>('[data-slot="action-list-inline-filter"]'))
777
- for (const n of nodes) n.style.removeProperty('display')
778
- let count = nodes.length
779
- while (count > 0 && overflowing()) {
780
- count -= 1
781
- const node = nodes[count]
782
- if (node !== undefined) node.style.display = 'none'
783
- }
784
- // Esgotou (zero filtros na linha) e AINDA não cabe → quebra a linha em vez de
785
- // recortar controle (busca no mínimo + segment + botões > largura disponível).
786
- if (count === 0 && overflowing()) row.classList.add('flex-wrap')
787
- setInlineFit(count >= nodes.length ? Number.POSITIVE_INFINITY : count)
788
- }
789
- compute()
790
- const ro = new ResizeObserver(compute)
791
- ro.observe(row)
792
- return () => ro.disconnect()
793
- // inlineFit na dep: o setInlineFit pode fazer o botão "Filtros" aparecer e
794
- // mudar a medida — o compute é idempotente, então re-rodar converge (sem loop).
795
- }, [inlineSpecs.length, state.filters, inlineFit])
796
- const overflowSpecs = Number.isFinite(inlineFit) ? inlineSpecs.slice(inlineFit) : []
797
- // O modal recebe os advanced + os inline que transbordaram (ordem do contrato).
798
- const modalSpecs = filterSpecs.filter(
799
- ([name, s]) => s.advanced === true || overflowSpecs.some(([n]) => n === name),
800
- )
801
- const modalActive = modalSpecs.filter(([name]) => !isEmptyValue(state.filters[name])).length
802
950
 
803
951
  // — Views: 'table' (quando há colunas) + as nomeadas. O segment só existe com 2+. —
804
952
  const viewEntries: Array<[string, string, ReactNode?]> = [
@@ -808,9 +956,6 @@ export function ActionList<TInput, TItem>({
808
956
  const activeView = state.view ?? viewEntries[0]?.[0] ?? 'table'
809
957
  const hasSegment = children === undefined && viewEntries.length > 1
810
958
  const periods = action.periods ?? []
811
- // Período é recorte obrigatório: o default é o preset marcado (ou o primeiro).
812
- const defaultPeriod = periods.find((p) => p.default === true)?.value ?? periods[0]?.value ?? null
813
- const activePeriod = state.period ?? defaultPeriod
814
959
  const hasToolbar = hasSearch || filterSpecs.length > 0 || periods.length > 0 || hasSegment || hasTable
815
960
 
816
961
  // — Input efetivo: escopo base + filtros preenchidos + período (→ from/to) + q + sort —
@@ -857,10 +1002,7 @@ export function ActionList<TInput, TItem>({
857
1002
  header: text(c.label, c.key),
858
1003
  sortable: c.sortable === true,
859
1004
  spec: c,
860
- className: cn(
861
- c.fit === true && 'w-px whitespace-nowrap',
862
- c.type === 'number' && 'text-right tabular-nums',
863
- ),
1005
+ className: cn(c.fit === true && 'w-px whitespace-nowrap'),
864
1006
  cell:
865
1007
  cells?.[c.key] ??
866
1008
  ((item: TItem): ReactNode => {
@@ -925,95 +1067,17 @@ export function ActionList<TInput, TItem>({
925
1067
  }
926
1068
  }
927
1069
 
928
- // — Chips: só dos filtros DO MODAL (advanced + inline transbordado): os inline
929
- // visíveis mostram o próprio estado; os do modal ficariam invisíveis sem isto —
930
- const chips = modalSpecs
931
- .filter(([name]) => !isEmptyValue(state.filters[name]))
932
- .map(([name, spec]) => {
933
- const raw = state.filters[name]
934
- // Mesma precedência do FilterField: runtime/estáticas > dictionary do provider.
935
- const fromProps = optionsFor(name, spec, filterOptions)
936
- const opts = fromProps.length > 0 ? fromProps : dictOptionsFor(spec, dicts)
937
- const display = (v: unknown): string => opts.find((o) => o.value === v)?.label ?? String(v)
938
- const value = Array.isArray(raw) ? raw.map(display).join(', ') : display(raw)
939
- return { name, label: text(spec.label, name), value }
940
- })
941
- const clearFilter = (name: string): void => {
942
- const next = { ...state.filters }
943
- delete next[name]
944
- setStateResetPage({ ...state, filters: next })
945
- }
946
-
947
- // A busca abre a linha SÓ quando é a única forma de recorte; com período ou
948
- // filtros no contrato, o período abre e a busca fica no grupo da direita.
949
- const searchFirst = hasSearch && periods.length === 0 && filterSpecs.length === 0
950
- // Largura AUTO (todos os controles em default h-9, alinham nativamente):
951
- // 64 com folga, encolhe até 36 no aperto — é o item flexível da linha.
952
- const searchBox = hasSearch ? (
953
- <ClearableInput
954
- className="w-64 min-w-36"
955
- leading={<Search />}
956
- applied={state.q}
957
- placeholder={text(action.text?.placeholder, 'Buscar…')}
958
- onApply={(v) => {
959
- if (v !== state.q) setStateResetPage({ ...state, q: v })
960
- }}
961
- />
962
- ) : null
963
-
964
1070
  const toolbar = hasToolbar ? (
965
- <div data-slot="action-list-toolbar" className="space-y-2">
966
- {/* Linha única (sem wrap): a busca encolhe primeiro; depois os filtros inline
967
- transbordam pro modal (medição no useLayoutEffect acima). */}
968
- <div ref={toolbarRowRef} className="flex items-end gap-3">
969
- {searchFirst && searchBox}
970
- {periods.length > 0 && (
971
- <Labeled label="Período">
972
- <PeriodControl
973
- periods={periods}
974
- period={activePeriod}
975
- from={state.from}
976
- to={state.to}
977
- onChange={(next) =>
978
- setStateResetPage({
979
- ...state,
980
- ...next,
981
- period: next.period === defaultPeriod ? null : next.period,
982
- })
983
- }
984
- />
985
- </Labeled>
986
- )}
987
- {inlineSpecs.map(([name, spec]) => (
988
- <Labeled key={name} label={text(spec.label, name)} slot="action-list-inline-filter">
989
- <FilterField
990
- name={name}
991
- spec={spec}
992
- value={state.filters[name]}
993
- allValues={state.filters}
994
- onChange={(v) =>
995
- setStateResetPage({ ...state, filters: cascadeClear(name, { ...state.filters, [name]: v }) })
996
- }
997
- options={optionsFor(name, spec, filterOptions)}
998
- />
999
- </Labeled>
1000
- ))}
1001
- {modalSpecs.length > 0 && (
1002
- <Button variant="outline" className="shrink-0" onClick={() => setAdvancedOpen(true)}>
1003
- <SlidersHorizontal className="h-3.5 w-3.5" />
1004
- Filtros
1005
- {modalActive > 0 && (
1006
- <Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
1007
- {modalActive}
1008
- </Badge>
1009
- )}
1010
- </Button>
1011
- )}
1012
- {/* O grupo da direita cede por min-w-0 (sem justify-end: o vazamento no
1013
- aperto cai pra DIREITA, onde o scrollWidth da detecção enxerga); a busca
1014
- aqui dentro é o item flexível — encolhe antes de filtro migrar pro modal. */}
1015
- <div className="ml-auto flex min-w-0 items-center gap-2">
1016
- {!searchFirst && searchBox}
1071
+ <div data-slot="action-list-toolbar">
1072
+ <ActionFilterBar
1073
+ action={action}
1074
+ state={state}
1075
+ onStateChange={(next) => setStateResetPage({ ...state, ...next })}
1076
+ filterOptions={filterOptions}
1077
+ onRefresh={() => refetch()}
1078
+ refreshing={isLoading}
1079
+ actions={
1080
+ <>
1017
1081
  {hasSegment && (
1018
1082
  <ToggleGroup
1019
1083
  type="single"
@@ -1039,20 +1103,6 @@ export function ActionList<TInput, TItem>({
1039
1103
  ))}
1040
1104
  </ToggleGroup>
1041
1105
  )}
1042
- <Tooltip>
1043
- <TooltipTrigger asChild>
1044
- <Button
1045
- variant="outline"
1046
- className="shrink-0"
1047
- aria-label="Recarregar"
1048
- onClick={() => void refetch()}
1049
- disabled={isLoading}
1050
- >
1051
- <RefreshCw className={cn('h-3.5 w-3.5', isLoading && 'animate-spin')} />
1052
- </Button>
1053
- </TooltipTrigger>
1054
- <TooltipContent>Recarregar</TooltipContent>
1055
- </Tooltip>
1056
1106
  {/* Configuração de EXIBIÇÃO — sempre presente (em qualquer view): colunas quando a
1057
1107
  tabela está ativa, itens por página sempre, e o que vier depois. */}
1058
1108
  <Popover>
@@ -1119,81 +1169,25 @@ export function ActionList<TInput, TItem>({
1119
1169
  </div>
1120
1170
  </PopoverContent>
1121
1171
  </Popover>
1122
- </div>
1123
- </div>
1124
- {chips.length > 0 && (
1125
- <div data-slot="action-list-chips" className="flex flex-wrap items-center gap-1.5">
1126
- {chips.map((chip) => (
1127
- <Badge key={chip.name} variant="secondary" className="gap-1 pr-1">
1128
- <span className="text-muted-foreground">{chip.label}:</span> {chip.value}
1129
- <button
1130
- type="button"
1131
- aria-label={`Remover filtro ${chip.label}`}
1132
- onClick={() => clearFilter(chip.name)}
1133
- className="rounded-sm text-muted-foreground hover:text-foreground"
1134
- >
1135
- <X className="size-3" />
1136
- </button>
1137
- </Badge>
1138
- ))}
1139
- </div>
1140
- )}
1141
- {modalSpecs.length > 0 && (
1142
- <AdvancedFiltersDialog
1143
- open={advancedOpen}
1144
- onOpenChange={setAdvancedOpen}
1145
- specs={modalSpecs}
1146
- values={state.filters}
1147
- cascade={cascadeClear}
1148
- onApply={(next) => setStateResetPage({ ...state, filters: next })}
1149
- filterOptions={filterOptions}
1150
- />
1151
- )}
1172
+ </>
1173
+ }
1174
+ />
1152
1175
  </div>
1153
1176
  ) : null
1154
1177
 
1155
1178
  // — Estados de carga —
1156
1179
  const busy = isFetching || extraLoading === true
1157
1180
  const isEmpty = empty !== undefined ? empty(items) : items.length === 0
1158
- let body: ReactNode
1159
- if (busy && items.length === 0 && !(empty !== undefined && !isEmpty)) {
1160
- body = (
1161
- <div className="space-y-2">
1162
- <Skeleton className="h-10 w-full" />
1163
- <Skeleton className="h-10 w-full" />
1164
- <Skeleton className="h-10 w-full" />
1165
- </div>
1166
- )
1167
- } else if (isError && error !== undefined) {
1168
- body = (
1169
- <div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm space-y-3">
1170
- <div>
1171
- <strong className="text-destructive">{error.code}</strong>
1172
- <p className="text-destructive">{error.message}</p>
1173
- </div>
1174
- <Button variant="outline" size="sm" onClick={() => void refetch()}>
1175
- Tentar de novo
1176
- </Button>
1177
- </div>
1178
- )
1179
- } else if (isEmpty) {
1180
- body = (
1181
- <div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
1182
- {emptyMessage}
1183
- </div>
1184
- )
1185
- } else if (children !== undefined) {
1181
+ let content: ReactNode
1182
+ if (children !== undefined) {
1186
1183
  // Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
1187
- body = children(items, () => refetch())
1184
+ content = children(items, () => refetch())
1188
1185
  } else if (activeView !== 'table' && views?.[activeView] !== undefined) {
1189
1186
  // View alternativa (board/galeria/…): mesma fonte e toolbar, outro renderer.
1190
- body = views[activeView].render(items, () => refetch())
1187
+ content = views[activeView].render(items, () => refetch())
1191
1188
  } else {
1192
- body = (
1193
- // Moldura da casa (igual à listagem de workspaces): quadro arredondado com o
1194
- // scroll horizontal contido; a última linha fica sem borda pelo TableBody.
1195
- <div className="overflow-hidden rounded-lg border border-border">
1196
- <Table>
1189
+ content = (
1190
+ <Table variant="framed">
1197
1191
  <TableHeader>
1198
1192
  <TableRow>
1199
1193
  {hasSelection && (
@@ -1271,9 +1265,19 @@ export function ActionList<TInput, TItem>({
1271
1265
  ))}
1272
1266
  </TableBody>
1273
1267
  </Table>
1274
- </div>
1275
1268
  )
1276
1269
  }
1270
+ const body = (
1271
+ <DataState
1272
+ loading={busy && items.length === 0 && !(empty !== undefined && !isEmpty)}
1273
+ error={isError ? error : null}
1274
+ empty={isEmpty}
1275
+ emptyText={emptyMessage}
1276
+ onRetry={() => refetch()}
1277
+ >
1278
+ {content}
1279
+ </DataState>
1280
+ )
1277
1281
 
1278
1282
  // — Barra de seleção (batch): aparece só com itens marcados —
1279
1283
  const selectionBar =
@@ -1402,7 +1406,7 @@ export function ActionList<TInput, TItem>({
1402
1406
  )}
1403
1407
  </AlertDialogHeader>
1404
1408
  <AlertDialogFooter>
1405
- <Button variant="outline" size="sm" onClick={() => setConfirming(null)}>
1409
+ <Button variant="ghost" size="sm" onClick={() => setConfirming(null)}>
1406
1410
  Cancelar
1407
1411
  </Button>
1408
1412
  <Button
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * <Page /> — o esqueleto de página do back-office.
3
3
  *
4
- * Padroniza o que toda página repete: <main> + container de largura CHEIA (o caso
5
- * comum do back-office; quem quiser estreito passa `className="max-w-5xl"`) + header
4
+ * Padroniza o que toda página repete: <main> + container centralizado com teto de 72rem
5
+ * (`max-w-6xl`; quem precisar de outra largura sobrescreve por `className`) + header
6
6
  * (título, descrição e ações à direita, alinhadas ao fim desse bloco — em geral o botão
7
7
  * de criar). O conteúdo é
8
8
  * children, sem nada imposto. Presentacional (sem contrato) de propósito: uma página
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { ReactNode } from 'react'
13
13
  import { cn } from '../../lib/cn.ts'
14
+ import { ContentHeaderFrame } from './content-header.tsx'
14
15
 
15
16
  export interface PageProps {
16
17
  title: string
@@ -20,7 +21,7 @@ export interface PageProps {
20
21
  description?: ReactNode
21
22
  /** Ações à direita do header, alinhadas ao fim do bloco de contexto. */
22
23
  actions?: ReactNode
23
- /** Classes do container. Default: largura cheia; `max-w-5xl` estreita e centra. */
24
+ /** Classes do container. Default: centralizado com `max-w-6xl` (72rem). */
24
25
  className?: string
25
26
  children: ReactNode
26
27
  }
@@ -28,23 +29,21 @@ export interface PageProps {
28
29
  export function Page({ title, count, description, actions, className, children }: PageProps): React.ReactElement {
29
30
  return (
30
31
  <main data-slot="page" className="min-w-0 flex-1">
31
- <div className={cn('mx-auto px-8 py-8', className)}>
32
- {/* As ações acompanham o fim do contexto, mesmo quando há uma descrição sob o título. */}
32
+ <div className={cn('mx-auto max-w-6xl px-8 py-8', className)}>
33
33
  <div data-slot="page-header" className="mb-6">
34
- <div className={cn(actions !== undefined && 'flex items-end justify-between gap-4')}>
35
- <div data-slot="page-heading" className="min-w-0">
36
- <h1 className="flex items-baseline gap-2 text-2xl font-semibold tracking-tight">
37
- {title}
38
- {count !== undefined && <span className="font-mono text-base text-muted-foreground/60">{count}</span>}
39
- </h1>
40
- {description !== undefined && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
41
- </div>
42
- {actions !== undefined && (
43
- <div data-slot="page-actions" className="flex shrink-0 items-center gap-4">
44
- {actions}
45
- </div>
46
- )}
47
- </div>
34
+ <ContentHeaderFrame
35
+ title={title}
36
+ meta={
37
+ count === undefined ? undefined : (
38
+ <span className="font-mono text-base text-muted-foreground/60">{count}</span>
39
+ )
40
+ }
41
+ description={description}
42
+ actions={actions}
43
+ level={1}
44
+ variant="page"
45
+ slots={{ heading: 'page-heading', actions: 'page-actions' }}
46
+ />
48
47
  </div>
49
48
  {children}
50
49
  </div>
@@ -123,7 +123,8 @@ export function ActionTrigger<TInput, TData>({
123
123
  const destructive = spec?.destructive === true
124
124
  // Com ícone o botão é chrome do item: fica ghost, e o vermelho aparece no hover — uma
125
125
  // lixeira sólida vermelha em cada linha seria um campo minado visual.
126
- const effectiveVariant = variant ?? (icon !== undefined ? 'ghost' : destructive ? 'destructive' : 'default')
126
+ const triggerVariant = variant ?? (icon !== undefined ? 'ghost' : destructive ? 'destructive' : 'default')
127
+ const confirmVariant = destructive ? 'destructive' : (variant ?? 'default')
127
128
 
128
129
  const fire = () => {
129
130
  void trigger(input)
@@ -135,7 +136,7 @@ export function ActionTrigger<TInput, TData>({
135
136
  const renderButton = (onClick: () => void): React.ReactElement => {
136
137
  const button = (
137
138
  <Button
138
- variant={effectiveVariant}
139
+ variant={triggerVariant}
139
140
  size={icon !== undefined ? 'icon' : size}
140
141
  busy={isLoading}
141
142
  disabled={disabled}
@@ -182,10 +183,10 @@ export function ActionTrigger<TInput, TData>({
182
183
  )}
183
184
  </AlertDialogHeader>
184
185
  <AlertDialogFooter>
185
- <Button variant="outline" onClick={() => setOpen(false)} disabled={isLoading}>
186
+ <Button variant="ghost" onClick={() => setOpen(false)} disabled={isLoading}>
186
187
  {confirm.cancelLabel ?? 'Cancelar'}
187
188
  </Button>
188
- <Button variant={effectiveVariant} busy={isLoading} onClick={fire}>
189
+ <Button variant={confirmVariant} busy={isLoading} onClick={fire}>
189
190
  {confirm.actionLabel ?? buttonLabel}
190
191
  </Button>
191
192
  </AlertDialogFooter>
@@ -158,7 +158,7 @@ function AlertDialogAction({
158
158
 
159
159
  function AlertDialogCancel({
160
160
  className,
161
- variant = "outline",
161
+ variant = "ghost",
162
162
  size = "default",
163
163
  ...props
164
164
  }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &