@sapphire-caitlyn/schema-kit-ng 0.0.4 → 0.0.6

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.
Files changed (37) hide show
  1. package/README.md +2 -0
  2. package/docs/inputs/date/input-date/README.md +37 -0
  3. package/docs/inputs/date/input-date-span/README.md +36 -0
  4. package/docs/inputs/date/input-date-time/README.md +32 -0
  5. package/docs/inputs/date/input-date-time-span/README.md +32 -0
  6. package/docs/inputs/date/input-time/README.md +28 -0
  7. package/docs/inputs/date/input-time-span/README.md +29 -0
  8. package/docs/inputs/input-base/README.md +42 -0
  9. package/docs/inputs/input-bool/README.md +30 -0
  10. package/docs/inputs/input-color/README.md +24 -0
  11. package/docs/inputs/input-color/color-picker/README.md +25 -0
  12. package/docs/inputs/input-cpf-cnpj/README.md +34 -0
  13. package/docs/inputs/input-file/README.md +33 -0
  14. package/docs/inputs/input-icon/README.md +28 -0
  15. package/docs/inputs/input-json/README.md +36 -0
  16. package/docs/inputs/input-mask/README.md +39 -0
  17. package/docs/inputs/input-number/README.md +44 -0
  18. package/docs/inputs/input-phone/README.md +25 -0
  19. package/docs/inputs/input-select/README.md +42 -0
  20. package/docs/inputs/input-string/README.md +32 -0
  21. package/docs/inputs/input-text-area/README.md +27 -0
  22. package/docs/schema-table/README.md +444 -0
  23. package/docs/schema-view/README.md +439 -0
  24. package/docs/ui/blurry-text-reveal/README.md +49 -0
  25. package/docs/ui/breadcrumbs/README.md +62 -0
  26. package/docs/ui/file-preview/README.md +60 -0
  27. package/docs/ui/infinte-scroll/README.md +10 -0
  28. package/docs/ui/loading/README.md +62 -0
  29. package/docs/ui/save-button/README.md +68 -0
  30. package/docs/ui/schema-dialog/README.md +154 -0
  31. package/docs/ui/suspended-slider/README.md +95 -0
  32. package/docs/ui/tree-drag-drop/README.md +202 -0
  33. package/fesm2022/sapphire-caitlyn-schema-kit-ng-helm.mjs.map +1 -1
  34. package/fesm2022/sapphire-caitlyn-schema-kit-ng.mjs +106 -103
  35. package/fesm2022/sapphire-caitlyn-schema-kit-ng.mjs.map +1 -1
  36. package/package.json +1 -1
  37. package/types/sapphire-caitlyn-schema-kit-ng.d.ts +93 -17
@@ -0,0 +1,27 @@
1
+ # input-text-area
2
+
3
+ Campo de texto com várias linhas (`<textarea>`).
4
+
5
+ ## Uso
6
+
7
+ ```ts
8
+ import { InputTextAreaComponent } from '@schema-kit';
9
+ ```
10
+
11
+ ```html
12
+ <input-text-area label="Observações" placeholder="Digite um texto" [formField]="form.observacoes" />
13
+ ```
14
+
15
+ ## Inputs
16
+
17
+ Só os comuns (`label`, `placeholder`, `loading`, `tooltipInfo`): [input-base](../input-base/README.md).
18
+
19
+ **Valor:** `string | null`
20
+
21
+ ## Slots
22
+
23
+ - `[slot=input]`: dentro do wrapper `relative` do campo.
24
+
25
+ ## Atenção
26
+
27
+ - O input `type` existe, mas não tem efeito no `<textarea>`.
@@ -0,0 +1,444 @@
1
+ # schema-table
2
+
3
+ Tela de listagem pronta a partir de um `SchemaService`: busca, um filtro por campo, ordenação, paginação e as ações de
4
+ criar, editar e excluir. As colunas, os filtros e a formatação de cada célula saem do schema do service.
5
+
6
+ Feita para trabalhar junto com o [schema-view](../schema-view/README.md) (a tela de criar/editar).
7
+
8
+ ## Uso
9
+
10
+ ```ts
11
+ import { SchemaTableComponent, SchemaTableActionsDirective, SchemaTableReplacementForDirective, SchemaTableToolbarDirective } from '@schema-kit';
12
+ ```
13
+
14
+ ### 1. O service
15
+
16
+ A tabela não recebe dados: recebe um `SchemaService`, que sabe o endpoint, o título e o schema dos campos. Os exemplos
17
+ deste README usam este service:
18
+
19
+ ```ts
20
+ import { inject, Injectable } from '@angular/core';
21
+ import { required } from '@angular/forms/signals';
22
+ import { FieldType, IView, SchemaService } from '@schema-kit';
23
+
24
+ export interface IProduto extends IView {
25
+ nome: string;
26
+ preco: number;
27
+ estoque: number;
28
+ ativo: boolean;
29
+ unidade: 'UN' | 'KG' | 'CX';
30
+ uuidCategoria: string | null;
31
+ descricao: string | null;
32
+ }
33
+
34
+ @Injectable({ providedIn: 'root' })
35
+ export class ProdutoService extends SchemaService<IProduto> {
36
+ constructor() {
37
+ // `this` não existe antes do super(): injete as dependências em variáveis locais
38
+ const categoriaService = inject(CategoriaService);
39
+
40
+ super({
41
+ endpoint: '/produtos',
42
+ title: 'Produtos',
43
+ bindLabel: 'nome', // texto do registro (confirmação de exclusão, selects de outros services)
44
+ bindValue: 'uuid', // valor do registro (selects de outros services)
45
+ schema: {
46
+ uuid: { type: FieldType.UUID, label: 'UUID', hidden: true },
47
+ nome: { type: FieldType.String, label: 'Nome', validators: required },
48
+ preco: { type: FieldType.Currency, label: 'Preço' },
49
+ estoque: { type: FieldType.Number, label: 'Estoque' },
50
+ ativo: { type: FieldType.Bool, label: 'Ativo', defaultValue: true },
51
+ uuidCategoria: {
52
+ type: FieldType.Select,
53
+ label: 'Categoria',
54
+ options: { service: () => categoriaService },
55
+ },
56
+ unidade: {
57
+ type: FieldType.Select,
58
+ label: 'Unidade',
59
+ options: {
60
+ lstOptions: [
61
+ { id: 'UN', nome: 'Unidade' },
62
+ { id: 'KG', nome: 'Quilo' },
63
+ { id: 'CX', nome: 'Caixa' },
64
+ ],
65
+ bindValue: 'id',
66
+ bindLabel: 'nome',
67
+ },
68
+ },
69
+ descricao: { type: FieldType.TextArea, label: 'Descrição', hidden: { hiddenList: true } },
70
+ },
71
+ });
72
+ }
73
+ }
74
+ ```
75
+
76
+ Opções do service usadas pela tabela:
77
+
78
+ ```ts
79
+ interface SchemaServiceOptions<T> {
80
+ endpoint: string; // base das requisições: `${apiUrl}${endpoint}`
81
+ title: string; // breadcrumb da listagem
82
+ schema: SchemaFieldOptionsRecord<T>; // campos (colunas, filtros e o form do schema-view)
83
+ listSchema?: SchemaFieldOptionsRecord<T>; // se informado, define as colunas no lugar de `schema`
84
+ breadcrumbs?: IBreadcrumbItem[]; // substitui o breadcrumb padrão ([{ label: title }])
85
+ bindValue: keyof T | ((value: T) => T[keyof T]);
86
+ bindLabel: keyof T | ((value: T) => string | T[keyof T]);
87
+ }
88
+ ```
89
+
90
+ ### 2. As rotas
91
+
92
+ A tabela navega relativo à rota em que está: **Novo** vai para `./view` e a edição para `./view/:uuid`. Monte as rotas
93
+ assim (é a mesma estrutura que o schema-view espera):
94
+
95
+ ```ts
96
+ const routes: Routes = [
97
+ { path: '', component: ProdutoListComponent },
98
+ { path: 'view', component: ProdutoViewComponent }, // criar
99
+ { path: 'view/:uuid', component: ProdutoViewComponent }, // editar
100
+ ];
101
+ ```
102
+
103
+ ### 3. O componente
104
+
105
+ ```ts
106
+ @Component({
107
+ selector: 'produto-list',
108
+ imports: [SchemaTableComponent],
109
+ template: `<schema-table [schemaService]="produtoService" />`,
110
+ })
111
+ export class ProdutoListComponent {
112
+ protected produtoService = inject(ProdutoService);
113
+ }
114
+ ```
115
+
116
+ Só com isso a tela tem: breadcrumb, campo de busca, um botão de filtro por campo, botões **Novo**, **Resetar filtros** e
117
+ **Atualizar**, cabeçalhos ordenáveis, coluna **Ações** (editar e excluir) e paginação.
118
+
119
+ ## Inputs
120
+
121
+ | Input | Tipo | Padrão | Descrição |
122
+ | ----------------- | ---------------------- | ----------- | ---------------------------------------------------------------------------- |
123
+ | `schemaService` | `SchemaService<T, IP>` | obrigatório | Fonte dos dados e do schema |
124
+ | `lstPresetFilters`| `IIndexFilter<T, any>[]` | `[]` | Filtros fixos, enviados sempre junto com os do usuário. Não aparecem na barra |
125
+ | `noQuery` | `boolean` | `false` | Esconde o campo de busca |
126
+ | `noNew` | `boolean` | `false` | Esconde o botão Novo |
127
+ | `editPermissao` | `string \| null` | `null` | Permissão para editar. `null`: sempre permitido. Ver [Permissões](#permissões) |
128
+ | `deletePermissao` | `string \| null` | `null` | Permissão para excluir. `null`: sempre permitido |
129
+
130
+ Two-way binding (`model`), para controlar ou ler o estado de fora:
131
+
132
+ | Model | Tipo | Padrão | Descrição |
133
+ | ------- | ----------------------- | --------------------------------- | ---------------------------- |
134
+ | `page` | `number` | `1` | Página atual |
135
+ | `limit` | `number` | `paginationOptions.rows` (20) | Itens por página |
136
+ | `sort` | `IIndexParamsSort<T>[]` | `[]` | Ordenação (várias colunas) |
137
+
138
+ ```html
139
+ <schema-table [schemaService]="produtoService" [(page)]="pagina" [(sort)]="ordenacao" />
140
+ ```
141
+
142
+ ## Outputs
143
+
144
+ | Output | Tipo | Descrição |
145
+ | ------ | ------- | ----------------------------------------------------------------------------------- |
146
+ | `novo` | `Event` | Clique em Novo, antes de navegar. `event.preventDefault()` cancela a navegação |
147
+
148
+ Abrir um dialog no lugar da tela de criação:
149
+
150
+ ```html
151
+ <schema-table [schemaService]="produtoService" (novo)="$event.preventDefault(); abrirDialogNovo()" />
152
+ ```
153
+
154
+ ## Métodos
155
+
156
+ | Método | Descrição |
157
+ | ----------- | ---------------------------------------------------------------------------- |
158
+ | `refresh()` | Recarrega a página atual com os mesmos filtros |
159
+ | `reset()` | Volta à página 1, limite padrão, sem ordenação, sem filtros e sem busca |
160
+
161
+ ```ts
162
+ export class ProdutoListComponent {
163
+ private table = viewChild.required(SchemaTableComponent);
164
+
165
+ async importarPlanilha(arquivo: File) {
166
+ await this.importacaoService.enviar(arquivo);
167
+ this.table().refresh();
168
+ }
169
+ }
170
+ ```
171
+
172
+ ## Templates
173
+
174
+ Passados como `<ng-template>` dentro do `<schema-table>`. Importe a diretiva correspondente no componente que usa a
175
+ tabela.
176
+
177
+ Cliques no conteúdo de um template (e no texto das células padrão) não chegam à linha: botões, links e inputs funcionam
178
+ sem `stopPropagation`. O resto da linha continua abrindo a edição.
179
+
180
+ ### Ações da linha: `schemaTableActions`
181
+
182
+ Botões extras em cada linha. Contexto: `$implicit` = o item da linha.
183
+
184
+ | Input | Tipo | Padrão | Descrição |
185
+ | ---------- | ------------------ | ------- | ---------------------------------------------------------------------- |
186
+ | `position` | `'start' \| 'end'` | `'end'` | `start`: coluna própria antes das colunas. `end`: na coluna **Ações**, entre editar e excluir |
187
+
188
+ ```html
189
+ <schema-table [schemaService]="produtoService">
190
+ <ng-template schemaTableActions let-produto>
191
+ <button hlmBtn size="icon-sm" variant="ghost" (click)="duplicar(produto)" hlmTooltip="Duplicar">
192
+ <ng-icon name="lucideCopy" />
193
+ </button>
194
+ </ng-template>
195
+
196
+ <ng-template schemaTableActions position="start" let-produto>
197
+ <input type="checkbox" (change)="alternarSelecao(produto)" />
198
+ </ng-template>
199
+ </schema-table>
200
+ ```
201
+
202
+ ### Barra de ferramentas: `schemaTableToolbar`
203
+
204
+ Conteúdo extra na barra do topo. Não tem contexto (`let-*` fica `undefined`).
205
+
206
+ | Input | Tipo | Padrão | Descrição |
207
+ | ---------- | ------------------ | ------- | ----------------------------------------------------------------- |
208
+ | `position` | `'start' \| 'end'` | `'end'` | `start`: depois dos filtros, antes de Novo. `end`: depois de Atualizar |
209
+
210
+ ```html
211
+ <schema-table [schemaService]="produtoService">
212
+ <ng-template schemaTableToolbar position="start">
213
+ <button hlmBtn size="sm" variant="outline" (click)="exportarCsv()">Exportar</button>
214
+ </ng-template>
215
+ </schema-table>
216
+ ```
217
+
218
+ ### Célula personalizada: `schemaTableReplacementFor`
219
+
220
+ Substitui a célula de uma coluna. O valor da diretiva é o `index` do campo.
221
+
222
+ Contexto (`ITemplateContext`):
223
+
224
+ ```ts
225
+ interface ITemplateContext<T, V> {
226
+ $implicit: T; // o item da linha
227
+ value: V; // item[index]
228
+ index: keyof T; // nome do campo
229
+ label: string; // label do campo
230
+ loading: boolean; // true enquanto a célula salva (switch de Bool)
231
+ readonly: boolean; // `readonly` do campo no schema
232
+ }
233
+ ```
234
+
235
+ ```html
236
+ <schema-table [schemaService]="produtoService">
237
+ <ng-template schemaTableReplacementFor="estoque" let-value="value" let-produto>
238
+ <span [class.text-destructive]="value === 0">
239
+ {{ value === 0 ? 'Esgotado' : value + ' ' + produto.unidade }}
240
+ </span>
241
+ </ng-template>
242
+ </schema-table>
243
+ ```
244
+
245
+ ## Colunas
246
+
247
+ As colunas são os campos de `listSchema` (se existir) ou de `schema`, na ordem em que foram declarados. Campos com
248
+ `hidden: true` ou `hidden: { hiddenList: true }` não viram coluna nem filtro.
249
+
250
+ O `label` vira o cabeçalho e o `tooltipInfo` mostra um ícone (?) ao lado dele.
251
+
252
+ ### Formatação padrão por tipo
253
+
254
+ | Tipo | Célula |
255
+ | ---------------------------------------- | -------------------------------------------------------------------------- |
256
+ | `String`, `UUID`, `TextArea` | O texto. Vazio: `-` |
257
+ | `Number` | `1.234` |
258
+ | `Decimal` | `1.234,50` |
259
+ | `Currency` | `R$ 1.234,50` |
260
+ | `Date` / `DateTime` / `Time` | `dd/MM/yyyy` / `dd/MM/yyyy HH:mm:ss` / `HH:mm:ss` |
261
+ | `DateSpan` / `DateTimeSpan` / `TimeSpan` | `início - fim`, no formato acima. Ponta aberta: `…` |
262
+ | `Cpf`, `Cnpj`, `CpfCnpj`, `Phone` | Com máscara |
263
+ | `Mask` | Com a `maskitoOptions` do campo |
264
+ | `Bool` | Switch que **salva na hora** (ver [Bool editável](#bool-editável)) |
265
+ | `Icon` | O ícone |
266
+ | `JSON` | Botão que abre um dialog com o JSON formatado e botão de copiar |
267
+ | `File` | Preview e nome de cada arquivo (URL ou lista de URLs) |
268
+ | `Select` com `service` | O `bindLabel` do registro relacionado (ver [Select com service](#select-com-service)) |
269
+ | `Select` com `lstOptions` | **O valor cru** (ex.: `KG`). Use um template para mostrar o label |
270
+
271
+ ### Template no schema: `listOptions.template`
272
+
273
+ Para formatar a coluna em todas as tabelas que usam o service, defina no próprio schema. Aceita:
274
+
275
+ - uma função `(item) => string`;
276
+ - um `TemplateRef` (recebe o mesmo contexto do `schemaTableReplacementFor`);
277
+ - uma classe de componente (renderizada sem inputs: não recebe o item).
278
+
279
+ ```ts
280
+ unidade: {
281
+ type: FieldType.Select,
282
+ label: 'Unidade',
283
+ options: { lstOptions: UNIDADES, bindValue: 'id', bindLabel: 'nome' },
284
+ listOptions: {
285
+ template: (produto: IProduto) => UNIDADES.find((u) => u.id === produto.unidade)?.nome ?? '-',
286
+ },
287
+ },
288
+ ```
289
+
290
+ ### Prioridade
291
+
292
+ 1. `schemaTableReplacementFor` no template da tabela;
293
+ 2. `listOptions.template` do schema;
294
+ 3. formatação padrão do tipo.
295
+
296
+ ### Select com service
297
+
298
+ Para colunas `Select` com `options.service`, a tabela busca os labels de uma vez para a página toda: um
299
+ `POST ${endpoint do outro service}/index` com os valores da página em `lstUuids`. Se o registro não voltar, a célula
300
+ mostra o valor cru.
301
+
302
+ ```jsonc
303
+ // POST /categorias/index
304
+ { "lstUuids": ["3f2a…", "9b1c…"], "page": 1, "size": 2 }
305
+ ```
306
+
307
+ ### Bool editável
308
+
309
+ A célula de um `Bool` é um switch. Ao clicar, a tabela envia `PATCH ${endpoint}/${uuid}` só com aquele campo, mostra um
310
+ loading na célula e um toast. Em caso de erro, recarrega a página para desfazer.
311
+
312
+ ```jsonc
313
+ // PATCH /produtos/3f2a…
314
+ { "ativo": false }
315
+ ```
316
+
317
+ Com `readonly: true` no schema, o switch aparece desabilitado e não salva. Para mostrar texto no lugar do switch,
318
+ substitua a célula:
319
+
320
+ ```html
321
+ <ng-template schemaTableReplacementFor="ativo" let-value="value">
322
+ {{ value ? 'Sim' : 'Não' }}
323
+ </ng-template>
324
+ ```
325
+
326
+ ## Busca, filtros e ordenação
327
+
328
+ - **Busca:** o texto vai em `query` (com debounce de 400 ms). `noQuery` esconde o campo.
329
+ - **Filtros:** um botão por coluna (campos de `listSchema` ou `schema` sem `hiddenList`). Cada um abre um popover com o
330
+ input do tipo do campo (debounce de 400 ms). O botão ganha um X para limpar quando há valor. Para filtrar por um campo
331
+ que não é coluna, use [filtros fixos](#filtros-fixos).
332
+ - **Ordenação:** clicar no cabeçalho alterna `ASC` → `DESC` → sem ordenação. Dá para ordenar por várias colunas: a ordem
333
+ dos cliques é a ordem enviada.
334
+ - **Resetar filtros** chama `reset()`: limpa busca, filtros, ordenação e volta à página 1.
335
+
336
+ ### Filtro por tipo
337
+
338
+ | Tipo | Input do filtro | `value` enviado |
339
+ | ----------------------------------- | ----------------------- | ------------------------------------------------ |
340
+ | `String`, `TextArea` | Texto | `string` |
341
+ | `UUID` | Texto | `string`. Só aplica quando é um UUID válido |
342
+ | `Mask` | Texto com a máscara | O retorno do `unmaskHandler` (sem ele, o texto mascarado) |
343
+ | `Phone`, `Cpf`, `Cnpj`, `CpfCnpj` | Input com máscara | Sem máscara (ex.: `12345678901`) |
344
+ | `Number`, `Decimal`, `Currency` | Mínimo e máximo | `[min, max]`. Uma ponta pode ser `null` |
345
+ | `Date` | Intervalo de datas | `[início, fim]` em ISO |
346
+ | `DateTime` | Intervalo de data/hora | `[início, fim]` em ISO |
347
+ | `Time` | Intervalo de horas | `[início, fim]` em `HH:mm:ss` |
348
+ | `Bool` | Sim / Não | `true` / `false` |
349
+ | `Select` | O mesmo select do campo | O `bindValue` do item escolhido |
350
+ | `Icon` | Seletor de ícone | Nome do ícone |
351
+ | `JSON`, `File`, `*Span` | Nenhum | O botão aparece, mas o popover fica vazio |
352
+
353
+ ### Filtros fixos
354
+
355
+ Para restringir a listagem sem o usuário poder mudar (ex.: produtos de uma categoria vinda da rota):
356
+
357
+ ```ts
358
+ export class ProdutosDaCategoriaComponent {
359
+ uuidCategoria = input.required<string>(); // parâmetro da rota, com withComponentInputBinding()
360
+
361
+ protected filtrosFixos = computed<IIndexFilter<IProduto, unknown>[]>(() => [
362
+ { index: 'uuidCategoria', value: this.uuidCategoria() },
363
+ ]);
364
+ }
365
+ ```
366
+
367
+ ```html
368
+ <schema-table [schemaService]="produtoService" [lstPresetFilters]="filtrosFixos()" />
369
+ ```
370
+
371
+ Filtros fixos com valor vazio (`null`, `''`, `[]`) são ignorados. Eles vão no mesmo `lstFilter` que os do usuário: se o
372
+ usuário filtrar o mesmo campo, os dois são enviados.
373
+
374
+ ## Permissões
375
+
376
+ `editPermissao` e `deletePermissao` recebem o nome de uma permissão, verificada pela função configurada no
377
+ `SchemaKitModule.forRoot`:
378
+
379
+ ```ts
380
+ SchemaKitModule.forRoot({
381
+ apiUrlGetter: (env) => env.apiUrl,
382
+ perfilCheckerFactory: () => {
383
+ const auth = inject(AuthService); // roda em contexto de injeção
384
+ return (permissao) => auth.temPermissao(permissao);
385
+ },
386
+ });
387
+ ```
388
+
389
+ ```html
390
+ <schema-table
391
+ [schemaService]="produtoService"
392
+ [editPermissao]="'produto.editar'"
393
+ [deletePermissao]="'produto.excluir'"
394
+ />
395
+ ```
396
+
397
+ | Permissão de edição | Efeito |
398
+ | ------------------- | ---------------------------------------------------------------------- |
399
+ | permitida | Clique na linha e botão → abrem `./view/:uuid` |
400
+ | negada | Sem botão de editar e a linha não é clicável |
401
+
402
+ | Permissão de exclusão | Efeito |
403
+ | --------------------- | ----------------------------------------------------------------------------------- |
404
+ | permitida | Botão de lixeira: confirma com `Deseja realmente excluir "<bindLabel>"?`, envia `DELETE ${endpoint}/${uuid}` e recarrega |
405
+ | negada | Sem botão de excluir |
406
+
407
+ Não há input para desligar editar/excluir: para uma tabela só de leitura, passe uma permissão que o checker nega. Sem
408
+ `perfilCheckerFactory`, toda permissão informada é negada.
409
+
410
+ ## Contrato com a API
411
+
412
+ A listagem chama `GET ${apiUrl}${endpoint}/index`. Strings vão como estão; os demais valores vão em JSON. Parâmetros
413
+ `null`, `undefined` e listas vazias não são enviados.
414
+
415
+ ```text
416
+ GET /produtos/index
417
+ ?page=2
418
+ &size=20
419
+ &query=caneta
420
+ &sort=[{"field":"preco","order":"DESC"},{"field":"nome","order":"ASC"}]
421
+ &lstFilter=[{"index":"ativo","value":true},{"index":"preco","value":[10,null]}]
422
+ ```
423
+
424
+ A resposta precisa seguir `IPaginatedResult` (a tabela usa `data` e `total`):
425
+
426
+ ```ts
427
+ interface IPaginatedResult<T> {
428
+ data: T[];
429
+ total: number; // total de registros com os filtros, para a paginação
430
+ page: number;
431
+ size: number;
432
+ }
433
+ ```
434
+
435
+ Cada item precisa ter `uuid` (`T extends IView`): é a chave da linha e o parâmetro da edição/exclusão.
436
+
437
+ ## Celular
438
+
439
+ Em telas em retrato (`portrait`), as linhas viram cards com `label: valor` de cada coluna. As ações `start` ficam no
440
+ topo do card e as `end` (com excluir) no rodapé. O botão de editar não aparece: o card inteiro abre a edição.
441
+
442
+ ## Atenção
443
+
444
+ - Mudar filtros, busca ou ordenação não volta para a página 1: em uma página alta, o resultado pode ficar vazio.