@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.
- package/CHANGELOG.md +26 -0
- package/package.json +2 -2
- package/src/ui/components/patterns/content-header.tsx +88 -0
- package/src/ui/components/patterns/data-state.tsx +32 -4
- package/src/ui/components/patterns/form.tsx +62 -46
- package/src/ui/components/patterns/list.tsx +240 -236
- package/src/ui/components/patterns/page.tsx +18 -19
- package/src/ui/components/patterns/trigger.tsx +5 -4
- package/src/ui/components/primitives/alert-dialog.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +3 -3
- package/src/ui/components/primitives/empty.tsx +1 -1
- package/src/ui/components/primitives/field.tsx +5 -5
- package/src/ui/components/primitives/icon-picker.tsx +6 -0
- package/src/ui/components/primitives/item.tsx +46 -8
- package/src/ui/components/primitives/select.tsx +7 -0
- package/src/ui/docs/content/action-form-card.md +1 -1
- package/src/ui/docs/content/action-form-dialog.md +2 -0
- package/src/ui/docs/content/action-list.md +20 -1
- package/src/ui/docs/content/alert-dialog.md +1 -1
- package/src/ui/docs/content/button.md +4 -4
- package/src/ui/docs/content/customization.md +1 -1
- package/src/ui/docs/content/data-state.md +6 -3
- package/src/ui/docs/content/dialog.md +2 -2
- package/src/ui/docs/content/drawer.md +2 -2
- package/src/ui/docs/content/empty.md +10 -0
- package/src/ui/docs/content/item.md +4 -3
- package/src/ui/docs/content/page.md +4 -4
- package/src/ui/docs/content/popover.md +1 -1
- package/src/ui/meta.ts +10 -4
- package/src/ui/react.tsx +10 -2
|
@@ -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"
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
{
|
|
970
|
-
{
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
|
1159
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1187
|
+
content = views[activeView].render(items, () => refetch())
|
|
1191
1188
|
} else {
|
|
1192
|
-
|
|
1193
|
-
|
|
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="
|
|
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
|
|
5
|
-
*
|
|
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:
|
|
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
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{actions
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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={
|
|
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="
|
|
186
|
+
<Button variant="ghost" onClick={() => setOpen(false)} disabled={isLoading}>
|
|
186
187
|
{confirm.cancelLabel ?? 'Cancelar'}
|
|
187
188
|
</Button>
|
|
188
|
-
<Button variant={
|
|
189
|
+
<Button variant={confirmVariant} busy={isLoading} onClick={fire}>
|
|
189
190
|
{confirm.actionLabel ?? buttonLabel}
|
|
190
191
|
</Button>
|
|
191
192
|
</AlertDialogFooter>
|